Skip to content

feat(worker): bindings for Cloudflare Workers custom spans (#899) - #1016

Open
Butch78 wants to merge 2 commits into
cloudflare:mainfrom
Butch78:feat/tracing-custom-spans
Open

feat(worker): bindings for Cloudflare Workers custom spans (#899)#1016
Butch78 wants to merge 2 commits into
cloudflare:mainfrom
Butch78:feat/tracing-custom-spans

Conversation

@Butch78

@Butch78 Butch78 commented Jun 18, 2026

Copy link
Copy Markdown

Summary

Adds worker::observability — a Rust binding for the cloudflare:workers custom-span tracing API (shipped 2026-06-16). This is the runtime capability @kflansburg flagged as the prerequisite in #899 ("Once [Workers Observability supports user-defined trace spans], it should be possible to create a tracing Subscriber to collect spans").

Closes #899.

What's added

worker::observability (binding, no new deps on worker):

  • enter_span(name, |span| ...) — sync custom span.
  • enter_span_async(name, |span| async move { ... }).await — for async handlers; the callback returns a Promise workerd awaits before closing the span.
  • start_active_span(name) -> Span — a span that outlives the callback, closed by Span::end(). Binds startActiveSpan (shipped 2026-07-28) for work whose end isn't a callback return: streams, and tracing's separate span create/close.
  • Span::set_attribute(key, value) (bool | number | string), Span::is_traced(), and Span::end() (idempotent, as in JS).
  • with_active_span(|span| ...) — exposes the innermost open span so a tracing layer can forward events onto it.

Spans nest automatically via the JS async context, so they appear in the trace waterfall with correct parent/child nesting next to the automatic fetch/KV/D1 spans.

examples/custom-spans — a runnable Worker demonstrating the binding plus a WorkersLayer (tracing_subscriber::Layer) that bridges tracing onto the platform: every span! / #[instrument] becomes a platform span for its full lifetime (on_new_spanstartActiveSpan, on_closeend()), and events land as attributes on the span they were emitted in. summarize() in the example is instrumented with plain #[instrument] and gets a platform-measured duration with no Workers-specific code at the call site.

Design notes

  • Why the WorkersLayer lives in the example, not worker. Keeping it in worker would add a tracing-subscriber dependency (and, via workspace feature unification with the existing tracing example, drag time into worker's build). The binding is useful on its own and stays dependency-free; the layer is ~90 lines anyone can copy, or we can lift it into worker behind a feature if you'd prefer — happy to do that here.
  • Span lifetimes. When this PR was opened the runtime API was callback-scoped only (enterSpan(name, cb), no imperative start/end), so a Layer could not bridge a span! lifetime: tracing creates and closes a span in two separate operations, and a single-threaded Worker cannot suspend an enterSpan callback in between. The runtime shipped startActiveSpan() + span.end() on 2026-07-28, which is that missing pair, so the second commit here binds it and rewrites WorkersLayer as a real lifetime bridge. Durations come from the platform either way, since guest timer resolution is clamped (the root of the zero-duration issue reported in [Feature] Integrate with tracing #899).
  • The one remaining limitation, documented in the code. Parenting follows the JS async context, which is entered only for the instant startActiveSpan runs its callback, so a bridged span parents under the nearest enclosing enter_span rather than under its tracing parent, and two nested tracing spans come out as siblings. Wrapping a subtree in enter_span gives the exact shape. Closing this fully would need a runtime primitive for attaching to an open span's context — happy to take that to the runtime team if it's wanted.
  • Span handles are held in a thread-local map keyed by tracing::Id, not in the registry's span extensions: a JS Span is !Send and extensions must be Send + Sync. A Worker isolate is single-threaded, so the two are equivalent here.
  • No unsafe. The sync path uses ScopedClosure::borrow_mut (the callback is immediate, so it can borrow non-'static state); the async path uses an owned 'static closure since the promise outlives the call.

Validation

Rebased onto main (5f2d6c9) and re-run on the repo's pinned 1.88.0 toolchain, mirroring every step of pullrequest.yml that this diff can affect:

  • cargo fmt --all -- --check
  • cargo clippy --features d1,queue --all-targets --workspace -- -D warnings
  • cargo clippy --all-features --package worker-sandbox --all-targets -- -D warnings
  • cargo check ✅ / cargo check -p custom-spans --target wasm32-unknown-unknown
  • cargo test -p worker-build ✅ (6 passed)
  • worker-build on examples/custom-spans produces a deployable Worker; the bundle emits import { tracing } from "cloudflare:workers" with enterSpan, startActiveSpan, setAttribute, isTraced, and end().
  • Cargo.lock change is additive (the example's tracing/tracing-subscriber); no existing versions bumped.

CI itself has never run here — the workflows are still behind the first-time-contributor approval gate.

A standalone version was also prototyped here for discussion: https://github.com/Butch78/cf-workers-rs-tracing

@Butch78

Butch78 commented Jul 20, 2026

Copy link
Copy Markdown
Author

@kflansburg — reviving this against your note in #899:

Once [Workers Observability supports user-defined trace spans], it should be possible to create a tracing Subscriber to collect spans.

That precondition shipped on 2026-06-16 (tracing.enterSpan in cloudflare:workers), and this PR is that Subscriber: enter_span / enter_span_async bindings plus a WorkersLayer example bridging tracing events onto platform spans. No new dependencies on the worker crate itself — the example carries tracing / tracing-subscriber.

On why platform-measured duration is the crux rather than a nicety: as @omarabid ran into in #899, hand-rolled timing inside a Worker fundamentally can't work — Date.now() returns the time of the last I/O and doesn't advance during execution, so user-measured spans collapse to identical start/end. I hit exactly this the past week building request tracing across a Worker → Durable Object → container chain: I/O boundaries are measurable, but CPU time reads as zero, so a span that wraps compute is indistinguishable from one that does nothing. The platform-supplied duration is the only way to close that gap.

CI hasn't been triggered on the branch — I believe it needs a maintainer to approve the workflow run. Happy to rebase, or to split the bindings from the example if a smaller first cut is easier to review.

@Butch78

Butch78 commented Jul 30, 2026

Copy link
Copy Markdown
Author

@guybedford — tagging you as the maintainer actively landing changes here, rather than continuing to ping on #899. Three small things, all of which I am happy to do the work on:

1. CI has never run on this PR. There are no check-runs on 92e3926: pullrequest.yml triggers on pull_request and I am a first-time contributor to this repo, so the workflows are sitting behind the first-time-contributor approval gate. Could you hit "Approve and run"? I can rebase first if you would rather approve a fresh head; the branch is only 2 commits behind main.

2. The one open design question in the description is now resolved upstream. The caveat was that the runtime API was callback-scoped only (enterSpan(name, cb)), so a tracing_subscriber::Layer could not transparently bridge span lifetimes, since tracing enters and exits a span in two separate operations. The runtime shipped tracing.startActiveSpan() + span.end() on 2026-07-28, which is exactly that primitive and maps onto on_enter/on_close. The PR stands on its own as-is, but say the word and I will add the start_active_span / Span::end bindings here and promote the example WorkersLayer into a full span-lifetime bridge, so #899 lands complete in one change.

3. Structure — tell me which shape you prefer and I will push it. Current layout is worker::observability plus worker/src/bindings/tracing.rs. I can instead match the worker::email encapsulation you landed in #996 (worker-sys/src/types/tracing.rs + worker::tracing), and/or lift WorkersLayer out of the example into worker behind a feature flag. Both are quick; I left them as questions rather than guessing.

Context on why the platform-measured duration matters rather than being a nicety: as @omarabid hit in #899, hand-rolled timing inside a Worker cannot work, because Date.now() returns the time of the last I/O and does not advance during execution. Platform spans are the only path to real durations from Rust Workers today.

Matthew Aylward and others added 2 commits July 30, 2026 10:25
…e#899)

Add `worker::observability` — a binding for the `cloudflare:workers`
`tracing.enterSpan` custom-span API (shipped 2026-06-16):

- `enter_span` (sync) and `enter_span_async` (for async handlers) open
  custom trace spans that nest under the automatic platform spans in the
  Workers Observability waterfall.
- `Span::set_attribute` / `Span::is_traced`.
- `with_active_span` exposes the innermost open span so a
  `tracing_subscriber::Layer` can forward `tracing` events onto it.

The binding stays dependency-free; the new `custom-spans` example shows a
`WorkersLayer` (kept out of `worker` to avoid a `tracing-subscriber`
dependency) plus the end-to-end usage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The custom-span API was callback-scoped only when this landed, so a
`tracing_subscriber::Layer` could not drive a span lifetime: `on_new_span`
and `on_close` are separate operations, and a Worker cannot suspend an
`enterSpan` callback between them. The runtime shipped `startActiveSpan()`
+ `span.end()` on 2026-07-28, which is that missing imperative pair.

- `worker::observability::start_active_span(name) -> Span` opens a span that
  outlives the callback; `Span::end` closes it (idempotent, as in JS).
- `WorkersLayer` now mirrors every `tracing` span onto a platform span for
  its full lifetime, so `span!` / `#[instrument]` get platform-measured
  durations with no Workers-specific code at the call site. Handles live in
  a thread-local map keyed by tracing `Id`: a JS `Span` is `!Send`, and
  registry extensions must be `Send + Sync`.
- Events record onto the span they were emitted in, falling back to the
  innermost `enter_span` when there is no enclosing `tracing` span.

Parenting still follows the JS async context, which is entered only for the
instant `startActiveSpan` runs its callback, so a bridged span parents under
the nearest enclosing `enter_span` rather than under its `tracing` parent.
Documented on `start_active_span` and in the example; closing it needs a
runtime primitive for attaching to an open span's context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Butch78
Butch78 force-pushed the feat/tracing-custom-spans branch from 92e3926 to e18f539 Compare July 30, 2026 08:44
@Butch78

Butch78 commented Jul 30, 2026

Copy link
Copy Markdown
Author

Update: pushed the span-lifetime work, so this now closes #899 rather than half of it, and rebased onto main (5f2d6c9).

New commit — binds startActiveSpan() + span.end() (shipped 2026-07-28) as start_active_span(name) -> Span / Span::end, and rewrites the example's WorkersLayer into a real bridge: on_new_spanstartActiveSpan, on_closeend(). span! and #[instrument] now produce platform spans with platform-measured durations, with no Workers-specific code at the call site. Span handles live in a thread-local map keyed by tracing::Id because a JS Span is !Send and registry extensions must be Send + Sync.

One limitation stays, and it is documented on start_active_span and in the example rather than hidden: parenting follows the JS async context, which is entered only for the instant startActiveSpan runs its callback, so a bridged span parents under the nearest enclosing enter_span and two nested tracing spans come out as siblings. enter_span still gives an exact subtree. Closing that gap needs a runtime primitive for attaching to an open span's context — happy to take it to the runtime team if that is wanted.

Correcting myself on structure: I offered above to move the bindings to worker-sys/src/types/tracing.rs. Please ignore that — those files are ts-gen output ("Generated by ts-gen. Do not edit."), and #996 established worker/src/bindings/*.rs as the home for hand-written bindings, which is where this already sits. The only deviation from #996 is naming: it kept module and binding aligned (worker::email / bindings/email.rs) where this is worker::observability / bindings/tracing.rs. Say the word and I will rename either half.

Validation on the rebased branch, pinned 1.88.0, mirroring every pullrequest.yml step this diff can affect: cargo fmt --check, both clippy invocations with -D warnings, cargo check, cargo check -p custom-spans --target wasm32-unknown-unknown, cargo test -p worker-build (6 passed), and a worker-build bundle that emits startActiveSpan and .end() in its glue. The repo's own CI still has not run on this PR — it is behind the first-time-contributor approval gate, so an "Approve and run" is the one thing I cannot do myself.

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.

[Feature] Integrate with tracing

1 participant