Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/effect-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,44 @@ When `MAPLE_INGEST_KEY` is unset, the SDK runs in no-op mode: buffers are draine
| `excludeLogSpans` | Skip Effect log spans in OTLP log attributes. Default `false` |
| `tracesPath` | OTLP traces path appended to `endpoint`. Default `/v1/traces` |
| `logsPath` | OTLP logs path appended to `endpoint`. Default `/v1/logs` |
| `tracer` | `"otlp"` (default) buffers spans for `flush(env)`; `"native"` mirrors them onto Cloudflare's own tracing — see [Native tracing](#native-tracing-experimental) |

The same `MAPLE_ENDPOINT` / `MAPLE_INGEST_KEY` / `MAPLE_ENVIRONMENT` env vars apply, read from the Workers `env` binding.

### Native tracing (experimental)

`tracer: "native"` hands span export to Cloudflare instead of the OTLP buffer. Every 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 the whole trace reaches Maple through the Worker's [ObservabilityDestination](https://developers.cloudflare.com/workers/observability/exporting-opentelemetry-data/). Nothing is buffered in the isolate, no ingest key is read, and it works from Durable Object and Workflow isolates, where a `ctx.waitUntil` flush is unreliable.

```typescript
const telemetry = MapleCloudflareSDK.make({ tracer: "native" })
// Handler wiring is unchanged; `telemetry.flush(env)` resolves immediately in this mode.
```

Requirements: `compatibility_date >= 2026-07-28` (for `startActiveSpan`), the `nodejs_compat` compatibility flag (for `AsyncLocalStorage.snapshot`, which is how a span opened after a fiber yields still nests under its parent), `observability.traces.enabled = true`, and an ObservabilityDestination pointed at Maple's OTLP endpoint. When either runtime API is missing the layer logs one notice and keeps spans Effect-local — the Worker keeps running, nothing is exported. The layer builds asynchronously (it imports both modules on first build); `HttpRouter.toWebHandler` handles that.

What is mirrored:

| Effect | Cloudflare span |
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| span name, nesting | span name, parent — including across `sleep`s, forks and other fiber yields |
| string / number / boolean attributes | forwarded as they are set |
| object / array / bigint attributes, events, links | Effect-local only |
| failed exit | `exception.type`, `exception.message`, `exception.stacktrace`, `error.type` (first error) |
| interrupt | `status.interrupted = true` |
| `anticipatedErrorIdentifiers` / `[ErrorReporter.ignore]` failures | no exception attributes |
| `dropSpanNames` match | no Cloudflare span; its children attach to the nearest mirrored ancestor |
| Cloudflare `isTraced = false` | the span and its descendants are unsampled — no `startActiveSpan` calls at all |

Cloudflare spans carry no events, so a failure is recorded as attributes rather than as the OTLP `exception` event. Maple's error tracking reads both shapes.

**Logs stay with Cloudflare.** Native mode installs no OTLP logger: Effect's default logger writes to `console`, which is Workers Logs, and the same ObservabilityDestination exports those next to the traces with Cloudflare's trace ids on them. Shipping Effect log records over OTLP would need exactly the flush and ingest key this mode removes, and every record would carry an Effect trace id that never matches the Cloudflare trace id on the exported spans. Metrics are likewise not exported in native mode.

**Trace ids.** `Effect.currentSpan`'s `traceId` / `spanId` are independent of Cloudflare's; the ids in the exported trace are Cloudflare's. Trace context is not yet propagated to services outside Cloudflare — see Cloudflare's [known limitations](https://developers.cloudflare.com/workers/observability/traces/known-limitations/).

**Async context.** A span runs its fibers inside the async context captured when it was opened. Two consequences: a root span opened after its fiber has already yielded (a forked background fiber with no parent span, say) attaches to whatever Cloudflare span is active at that moment, and code in the same continuation right after a span ends can still see that span as active. `HttpMiddleware.tracer` opens the request span synchronously inside the handler, which is the well-behaved case.

Resource attributes (`serviceName`, `environment`, `attributes`) are not applied in native mode; the export carries Cloudflare's own resource attributes for the Worker.

## Client (Browser)

All configuration must be provided programmatically since browsers don't have access to environment variables.
Expand Down
28 changes: 27 additions & 1 deletion packages/effect-sdk/src/cloudflare/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assert, describe, it } from "@effect/vitest"
import { Duration, Effect, Fiber, Layer } from "effect"
import { Duration, Effect, Fiber, Layer, Logger } from "effect"
import { TestClock } from "effect/testing"
import { afterEach, expect, vi } from "vitest"
import { make } from "./index.js"
Expand Down Expand Up @@ -383,4 +383,30 @@ describe("MapleCloudflareSDK.make", () => {
expect(a).toBe(b)
expect(Layer.isLayer(a)).toBe(true)
})

// Native mode never touches the network: Cloudflare exports the spans. Off
// Workers (here: Node, no `cloudflare:workers`) the layer must still build
// and keep spans Effect-local rather than fail the host.
it("native mode: no fetch, flush resolves, and spans fall back to Effect-local off Workers", async () => {
const { calls, restore: r } = setupFetch()
restore = r
const notices: Array<string> = []
const capture = Logger.make<unknown, void>(({ message }) => {
notices.push(Array.isArray(message) ? message.join(" ") : String(message))
})
const telemetry = make({ serviceName: "unit-test", tracer: "native" })

const span = await Effect.runPromise(
Effect.currentSpan.pipe(
Effect.withSpan("op"),
Effect.provide(telemetry.layer.pipe(Layer.provide(Logger.layer([capture])))),
),
)
await telemetry.flush(env)

expect(span.name).toBe("op")
expect(calls.length).toBe(0)
expect(notices).toHaveLength(1)
expect(notices[0]).toContain("native tracing unavailable")
})
})
24 changes: 24 additions & 0 deletions packages/effect-sdk/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js"
import { makeNoOpNotice } from "../shared/no-op-notice.js"
import { resolveResourceFromEnv } from "../server/resource.js"
import { SDK_VERSION } from "../version.js"
import { makeNativeTracerLayer } from "./native-tracer.js"

export interface Config {
/**
Expand Down Expand Up @@ -101,6 +102,19 @@ export interface Config {
readonly logsPath?: string | undefined
/** OTLP metrics path appended to `endpoint`. Default `/v1/metrics`. */
readonly metricsPath?: string | undefined
/**
* How spans leave the Worker.
*
* - `"otlp"` (default): spans, logs and metrics are buffered in the isolate
* and POSTed to Maple on `flush(env)`.
* - `"native"` (experimental): every span is mirrored onto Cloudflare's
* `tracing.startActiveSpan`, so it is exported by the Worker's
* ObservabilityDestination in the same trace as Cloudflare's own
* fetch/KV/R2/D1 spans. No ingest key, `flush` is a no-op, and logs are
* left to Workers Logs. Needs `compatibility_date >= 2026-07-28` and the
* `nodejs_compat` flag; when either is missing, spans stay Effect-local.
*/
readonly tracer?: "otlp" | "native" | undefined
}

export interface Telemetry {
Expand Down Expand Up @@ -147,6 +161,16 @@ export const make = (config: Config = {}): Telemetry => {
]
const anticipatedIdentifiers =
anticipatedErrorIdentifiers.length > 0 ? new Set(anticipatedErrorIdentifiers) : undefined

if (config.tracer === "native") {
return {
layer: makeNativeTracerLayer({ dropSpan, anticipatedErrorIdentifiers: anticipatedIdentifiers }),
// Cloudflare exports the mirrored spans itself; `flush` stays so the
// handler wiring is the same in both modes.
flush: () => Promise.resolve(),
}
}

const spans: SpanBuffer = makeSpanBuffer({
dropSpan,
anticipatedErrorIdentifiers: anticipatedIdentifiers,
Expand Down
Loading