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

- `enter_span(name, |span| ...)` and `enter_span_async(name, |span| async { ... })` open custom trace spans that nest under the automatic platform spans in the Workers Observability waterfall.
- `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 shows a ready-made `WorkersLayer` doing exactly that. 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"
25 changes: 25 additions & 0 deletions examples/custom-spans/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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` (the `tracing` feature) forwarding ordinary `tracing::info!`
events onto the active platform span as attributes.

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` and its nested `load_rows` 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"
}
}
97 changes: 97 additions & 0 deletions examples/custom-spans/src/layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! `WorkersLayer` — a `tracing_subscriber::Layer` that forwards `tracing`
//! events and span fields onto the active Workers platform span.
//!
//! ## Why it's shaped this way
//!
//! `tracing` models a span lifetime as two separate operations — `on_enter`
//! and a later `on_exit`, driven by a guard's `Drop`. The platform models it
//! as one **callback-scoped** operation (`enterSpan(name, cb)`); there is no
//! imperative start/end. A `Layer` therefore can't bridge an arbitrary
//! `tracing::span!` to a platform span: at `on_enter` it would have to call
//! `enterSpan` and not return from its callback until the separate `on_exit`,
//! which a single-threaded Worker can't suspend and resume. Durations have to
//! come from the platform anyway, since guest timer resolution is clamped.
//!
//! So the work splits: span **structure + timing** come from wrapping work in
//! [`worker::observability::enter_span`] / `enter_span_async` (closure-scoped,
//! so they map onto `enterSpan` and nest via the JS async context); **events +
//! fields** are forwarded by this layer onto the active span via
//! [`worker::observability::with_active_span`].
//!
//! 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.

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::{with_active_span, Span};

/// Forwards `tracing` events and span fields onto the active platform span as
/// attributes. Install it on a `tracing_subscriber` registry and establish
/// platform spans with `worker::observability::enter_span[_async]`.
#[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 prefix = attrs.metadata().name();
with_active_span(|span| attrs.record(&mut AttrVisitor { span, prefix }));
}

fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) {
let prefix = ctx.span(id).map(|s| s.name()).unwrap_or("span");
with_active_span(|span| values.record(&mut AttrVisitor { span, prefix }));
}

fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let prefix = event.metadata().level().as_str();
with_active_span(|span| event.record(&mut AttrVisitor { span, prefix }));
}
}

/// Writes each visited `tracing` field as a typed `setAttribute` on the
/// platform span, under the key `"<prefix>.<field>"`.
struct AttrVisitor<'a> {
span: &'a Span,
prefix: &'a str,
}

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

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());
}
}
51 changes: 51 additions & 0 deletions examples/custom-spans/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! 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` forward `tracing` events onto the active span.
//! Deploy with `observability.traces` enabled (see `wrangler.toml`) and the
//! spans appear in the trace waterfall alongside the automatic `fetch` span.

mod layer;

use layer::WorkersLayer;
use tracing::info;
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
});

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

/// Stand-in for real work — a Worker would hit D1 / KV / a binding here.
fn expensive_query() -> u32 {
1234
}
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;
71 changes: 71 additions & 0 deletions worker/src/bindings/tracing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Raw `wasm-bindgen` import of the `cloudflare:workers` `tracing` API.
//!
//! Hand-written (not `ts-gen`-generated) because the safe wrapper in
//! [`crate::tracing`] needs a couple of shapes `ts-gen` doesn't express well —
//! the two `enterSpan` callback forms (sync vs. promise-returning) and the
//! `setAttribute` value overloads.
//!
//! Platform surface (`@cloudflare/workers-types`):
//!
//! ```ts
//! interface Tracing {
//! enterSpan<T, A extends unknown[]>(
//! name: string,
//! callback: (span: Span, ...args: A) => T,
//! ...args: A
//! ): T;
//! }
//! declare abstract class Span {
//! get isTraced(): boolean;
//! setAttribute(key: string, value?: boolean | number | string): void;
//! }
//! ```

use js_sys::Promise;
use wasm_bindgen::closure::ScopedClosure;
use wasm_bindgen::prelude::*;

#[wasm_bindgen(module = "cloudflare:workers")]
extern "C" {
/// The `tracing` namespace object exported by `cloudflare:workers`.
/// Thread-local because a Worker isolate is single-threaded and the
/// binding is not `Sync`.
#[wasm_bindgen(thread_local_v2, js_name = tracing)]
pub(crate) static TRACING: Tracing;

#[wasm_bindgen(js_name = Tracing)]
pub(crate) type Tracing;

/// `enterSpan` with a synchronous callback: the span closes when the
/// callback returns.
#[wasm_bindgen(method, js_name = enterSpan)]
pub(crate) fn enter_span_sync(this: &Tracing, name: &str, cb: &ScopedClosure<dyn FnMut(Span)>);

/// `enterSpan` with an async callback: the callback returns a `Promise`
/// and workerd keeps the span open until it settles. `enterSpan` returns
/// that same promise.
#[wasm_bindgen(method, js_name = enterSpan)]
pub(crate) fn enter_span_async(
this: &Tracing,
name: &str,
cb: &Closure<dyn FnMut(Span) -> Promise>,
) -> Promise;

/// A live span handle. Refcounted JS object — cloning is cheap and a clone
/// stays valid while the span is open.
#[wasm_bindgen(js_name = Span)]
#[derive(Debug, Clone)]
pub(crate) type Span;

// `setAttribute(key, value)` is `boolean | number | string` in JS; bind one
// overload per kind so the safe wrapper stays typed.
#[wasm_bindgen(method, js_name = setAttribute)]
pub(crate) fn set_attribute_bool(this: &Span, key: &str, value: bool);
#[wasm_bindgen(method, js_name = setAttribute)]
pub(crate) fn set_attribute_num(this: &Span, key: &str, value: f64);
#[wasm_bindgen(method, js_name = setAttribute)]
pub(crate) fn set_attribute_str(this: &Span, key: &str, value: &str);

#[wasm_bindgen(method, getter, js_name = isTraced)]
pub(crate) fn is_traced(this: &Span) -> bool;
}
1 change: 1 addition & 0 deletions worker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ mod headers;
mod http;
mod hyperdrive;
pub mod kv;
pub mod observability;
#[cfg(feature = "queue")]
mod queue;
mod r2;
Expand Down
Loading