feat(worker): bindings for Cloudflare Workers custom spans (#899) - #1016
feat(worker): bindings for Cloudflare Workers custom spans (#899)#1016Butch78 wants to merge 2 commits into
Conversation
|
@kflansburg — reviving this against your note in #899:
That precondition shipped on 2026-06-16 ( 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 — 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. |
|
@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 2. The one open design question in the description is now resolved upstream. The caveat was that the runtime API was callback-scoped only ( 3. Structure — tell me which shape you prefer and I will push it. Current layout is 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 |
…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>
92e3926 to
e18f539
Compare
|
Update: pushed the span-lifetime work, so this now closes #899 rather than half of it, and rebased onto New commit — binds One limitation stays, and it is documented on Correcting myself on structure: I offered above to move the bindings to Validation on the rebased branch, pinned 1.88.0, mirroring every |
Summary
Adds
worker::observability— a Rust binding for thecloudflare:workerscustom-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 onworker):enter_span(name, |span| ...)— sync custom span.enter_span_async(name, |span| async move { ... }).await— forasynchandlers; the callback returns aPromiseworkerd awaits before closing the span.start_active_span(name) -> Span— a span that outlives the callback, closed bySpan::end(). BindsstartActiveSpan(shipped 2026-07-28) for work whose end isn't a callback return: streams, andtracing's separate span create/close.Span::set_attribute(key, value)(bool | number | string),Span::is_traced(), andSpan::end()(idempotent, as in JS).with_active_span(|span| ...)— exposes the innermost open span so atracinglayer 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 aWorkersLayer(tracing_subscriber::Layer) that bridgestracingonto the platform: everyspan!/#[instrument]becomes a platform span for its full lifetime (on_new_span→startActiveSpan,on_close→end()), 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
WorkersLayerlives in the example, notworker. Keeping it inworkerwould add atracing-subscriberdependency (and, via workspace feature unification with the existingtracingexample, dragtimeintoworker'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 intoworkerbehind a feature if you'd prefer — happy to do that here.enterSpan(name, cb), no imperative start/end), so aLayercould not bridge aspan!lifetime:tracingcreates and closes a span in two separate operations, and a single-threaded Worker cannot suspend anenterSpancallback in between. The runtime shippedstartActiveSpan()+span.end()on 2026-07-28, which is that missing pair, so the second commit here binds it and rewritesWorkersLayeras 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).startActiveSpanruns its callback, so a bridged span parents under the nearest enclosingenter_spanrather than under itstracingparent, and two nestedtracingspans come out as siblings. Wrapping a subtree inenter_spangives 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.Spanhandles are held in a thread-local map keyed bytracing::Id, not in the registry's span extensions: a JSSpanis!Sendand extensions must beSend + Sync. A Worker isolate is single-threaded, so the two are equivalent here.unsafe. The sync path usesScopedClosure::borrow_mut(the callback is immediate, so it can borrow non-'staticstate); the async path uses an owned'staticclosure 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 ofpullrequest.ymlthat 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-buildonexamples/custom-spansproduces a deployable Worker; the bundle emitsimport { tracing } from "cloudflare:workers"withenterSpan,startActiveSpan,setAttribute,isTraced, andend().Cargo.lockchange is additive (the example'stracing/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