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
12 changes: 12 additions & 0 deletions .changeset/tracing-custom-spans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"workers-rs": minor
---

Add `worker::observability` — bindings for Cloudflare Workers [custom spans](https://developers.cloudflare.com/changelog/post/2026-06-16-custom-spans/) (`cloudflare:workers` `enterSpan` / `startActiveSpan`).

- `enter_span(name, |span| ...)` and `enter_span_async(name, |span| async { ... })` open callback-scoped custom trace spans that nest under the automatic platform spans in the Workers Observability waterfall.
- `start_active_span(name)` opens a span that outlives the callback and is closed by `Span::end` — for streams, and for bridging `tracing`'s separate span create/close.
- `Span::set_attribute` / `Span::is_traced` attach metadata and check sampling.
- `with_active_span` exposes the innermost open span so a `tracing_subscriber::Layer` can forward `tracing` events/fields onto it.

The new `custom-spans` example ships a `WorkersLayer` that bridges `tracing` span *lifetimes* onto the platform, so `span!` / `#[instrument]` get platform-measured durations with no Workers-specific code at the call site. Addresses #899.
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions examples/custom-spans/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "custom-spans"
version = "0.1.0"
edition = "2021"

[package.metadata.release]
release = false

[lib]
crate-type = ["cdylib"]

[dependencies]
# Binding comes from `worker`; the WorkersLayer (src/layer.rs) needs tracing-subscriber.
worker.workspace = true
tracing = "0.1"
tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] }
console_error_panic_hook = "0.1"
32 changes: 32 additions & 0 deletions examples/custom-spans/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# custom-spans

Custom trace spans for Workers Observability, in Rust — using
[`worker::observability`](../../worker/src/observability.rs).

It demonstrates:

- `enter_span_async("handle_request", |span| async move { … })` — an async
root span around the request handler.
- `enter_span("load_rows", |span| …)` — a nested sync span that auto-parents
under the root via the JS async context.
- `span.set_attribute(...)` and `span.is_traced()`.
- `WorkersLayer` — a `tracing_subscriber::Layer` bridging `tracing` onto the
platform: every `span!` / `#[instrument]` becomes a platform span for its
full lifetime (via `start_active_span` + `Span::end`), and `tracing::info!`
events land as attributes on the span they were emitted in. `summarize()` in
`src/lib.rs` is instrumented that way, with no Workers-specific code.

Nesting note: a bridged span parents under the nearest enclosing `enter_span`,
not under its `tracing` parent, because the platform derives hierarchy from the
JS async context. Wrap a subtree in `enter_span` where the shape matters.

Custom spans are recorded only when tracing is enabled in your Worker's
observability config — see `wrangler.toml` (`[observability.traces]`).

```sh
npx wrangler deploy
```

Then open the Worker's **Observability → Traces** view and trigger a request;
`handle_request`, its nested `load_rows` span, and the `tracing`-instrumented
`summarize` span appear in the waterfall next to the automatic `fetch` span.
12 changes: 12 additions & 0 deletions examples/custom-spans/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "custom-spans",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "cargo install worker-build ; wrangler deploy",
"dev": "cargo install worker-build ; wrangler dev --local"
},
"devDependencies": {
"wrangler": "^4"
}
}
160 changes: 160 additions & 0 deletions examples/custom-spans/src/layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! `WorkersLayer` — a `tracing_subscriber::Layer` that turns every
//! `tracing::span!` into a real Workers platform span and forwards `tracing`
//! events onto it.
//!
//! ## Why it's shaped this way
//!
//! `tracing` models a span lifetime as two separate operations: the span is
//! created (`on_new_span`) and closed (`on_close`) at unrelated points in time.
//! The original custom-span API was callback-scoped only (`enterSpan(name,
//! cb)`), which a `Layer` can't drive: at `on_new_span` it would have to call
//! `enterSpan` and not return from its callback until the later `on_close`,
//! which a single-threaded Worker can't suspend and resume.
//!
//! [`startActiveSpan()` + `span.end()`][changelog] (2026-07-28) are exactly the
//! imperative pair that lifetime needs, so this layer now bridges span
//! *lifetimes*, not just events: `#[tracing::instrument]` and `span!` produce
//! spans in the trace waterfall with platform-measured durations, with no
//! Workers-specific code at the call site.
//!
//! ## The one limitation
//!
//! Parent-child nesting follows the platform's async context, which is only
//! entered for the instant `startActiveSpan` runs its callback. A bridged span
//! therefore parents under the nearest enclosing span opened by
//! [`worker::observability::enter_span`] / `enter_span_async`, and two nested
//! `tracing` spans come out as siblings under that same parent rather than one
//! inside the other. Wrap a subtree in `enter_span` where the shape matters;
//! closing the gap entirely needs a runtime primitive for attaching to an open
//! span's context.
//!
//! This lives in the example rather than the `worker` crate so `worker` stays
//! free of a `tracing-subscriber` dependency. Copy it into your project, or
//! lift it into `worker` behind a feature if your project wants it there.
//!
//! [changelog]: https://developers.cloudflare.com/changelog/post/2026-07-28-start-active-span/

use std::cell::RefCell;
use std::collections::HashMap;

use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
use worker::observability::{start_active_span, with_active_span, Span};

thread_local! {
/// Platform span per live `tracing` span id. A thread-local map rather than
/// the registry's own span extensions because a JS [`Span`] is `!Send` and
/// extensions must be `Send + Sync`; a Worker isolate is single-threaded,
/// so a thread-local is equivalent here. Entries are removed in `on_close`,
/// which `tracing` calls exactly once per span.
static OPEN: RefCell<HashMap<u64, Span>> = RefCell::new(HashMap::new());
}

/// Bridges `tracing` onto Workers Observability: each `tracing` span becomes a
/// platform span for its full lifetime, and events land as attributes on the
/// span they were emitted in. Install it on a `tracing_subscriber` registry.
#[derive(Debug, Default, Clone, Copy)]
pub struct WorkersLayer;

impl<S> Layer<S> for WorkersLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, _ctx: Context<'_, S>) {
let span = start_active_span(attrs.metadata().name());
attrs.record(&mut AttrVisitor {
span: &span,
prefix: None,
});
OPEN.with_borrow_mut(|open| open.insert(id.into_u64(), span));
}

fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
with_span(id, |span| {
values.record(&mut AttrVisitor { span, prefix: None })
});
}

fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
// Events belong to the span they were emitted in; fall back to the
// innermost `enter_span` when there is no enclosing `tracing` span.
let level = event.metadata().level().as_str();
let recorded = ctx.event_span(event).is_some_and(|s| {
with_span(&s.id(), |span| {
event.record(&mut AttrVisitor {
span,
prefix: Some(level),
})
})
.is_some()
});

if !recorded {
with_active_span(|span| {
event.record(&mut AttrVisitor {
span,
prefix: Some(level),
})
});
}
}

fn on_close(&self, id: Id, _ctx: Context<'_, S>) {
if let Some(span) = OPEN.with_borrow_mut(|open| open.remove(&id.into_u64())) {
span.end();
}
}
}

/// Run `f` against the platform span backing `id`, if it is still open.
fn with_span<R>(id: &Id, f: impl FnOnce(&Span) -> R) -> Option<R> {
OPEN.with_borrow(|open| open.get(&id.into_u64()).map(f))
}

/// Writes each visited `tracing` field as a typed `setAttribute` on the
/// platform span. Span fields keep their own names; event fields are prefixed
/// with the level (`"INFO.message"`) so they don't collide with them.
struct AttrVisitor<'a> {
span: &'a Span,
prefix: Option<&'a str>,
}

impl AttrVisitor<'_> {
fn key(&self, field: &Field) -> String {
match self.prefix {
Some(prefix) => format!("{}.{}", prefix, field.name()),
None => field.name().to_owned(),
}
}
}

impl Visit for AttrVisitor<'_> {
fn record_bool(&mut self, field: &Field, value: bool) {
self.span.set_attribute(&self.key(field), value);
}

fn record_i64(&mut self, field: &Field, value: i64) {
self.span.set_attribute(&self.key(field), value);
}

fn record_u64(&mut self, field: &Field, value: u64) {
self.span.set_attribute(&self.key(field), value);
}

fn record_f64(&mut self, field: &Field, value: f64) {
self.span.set_attribute(&self.key(field), value);
}

fn record_str(&mut self, field: &Field, value: &str) {
self.span.set_attribute(&self.key(field), value);
}

fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.span
.set_attribute(&self.key(field), format!("{value:?}").as_str());
}
}
65 changes: 65 additions & 0 deletions examples/custom-spans/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Custom trace spans for Workers Observability, in Rust.
//!
//! Wraps the request in an async platform span, nests a sync span under it, and
//! lets the `WorkersLayer` turn ordinary `tracing` spans and events into
//! platform spans and attributes. Deploy with `observability.traces` enabled
//! (see `wrangler.toml`) and they appear in the trace waterfall alongside the
//! automatic `fetch` span.

mod layer;

use layer::WorkersLayer;
use tracing::{info, instrument};
use tracing_subscriber::prelude::*;
use worker::observability::{enter_span, enter_span_async};
use worker::{event, Context, Env, Request, Response, Result};

#[event(start)]
fn start() {
console_error_panic_hook::set_once();
// `try_init` so a hot-reloaded isolate doesn't panic on a second install.
let _ = tracing_subscriber::registry().with(WorkersLayer).try_init();
}

#[event(fetch)]
async fn fetch(req: Request, _env: Env, _ctx: Context) -> Result<Response> {
let path = req.path();

enter_span_async("handle_request", move |span| async move {
span.set_attribute("http.path", path.as_str());
span.set_attribute("sampled", span.is_traced());

// A plain `tracing` event — the layer forwards `user_id` as an
// attribute on `handle_request`. No platform-specific code here.
info!(user_id = 42, "request received");

// A nested sync span; auto-parents under `handle_request`.
let rows = enter_span("load_rows", |child| {
let rows = expensive_query();
child.set_attribute("db.rows", rows);
rows
});

// ...and the same work instrumented the ordinary `tracing` way. The
// layer opens a platform span on entry and ends it on close, so this
// gets a platform-measured duration with no Workers-specific code.
let total = summarize(rows);

info!(rows, "query complete");
Response::ok(format!("loaded {rows} rows for {path}, total {total}"))
})
.await
}

/// Stand-in for real work — a Worker would hit D1 / KV / a binding here.
fn expensive_query() -> u32 {
1234
}

/// Instrumented with plain `tracing`: `WorkersLayer` bridges the span lifetime
/// onto `startActiveSpan` / `span.end()`, and `rows` becomes an attribute.
#[instrument]
fn summarize(rows: u32) -> u32 {
info!("summarizing");
rows * 2
}
14 changes: 14 additions & 0 deletions examples/custom-spans/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name = "custom-spans"
main = "build/worker/shim.mjs"
compatibility_date = "2026-06-16"

[build]
command = "cargo install \"worker-build@^0.8\" && worker-build --release"

# Custom spans are only recorded when tracing is enabled in observability.
[observability]
enabled = true

[observability.traces]
enabled = true
head_sampling_rate = 1
1 change: 1 addition & 0 deletions worker/src/bindings/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod email;
pub mod tracing;
Loading