Custom spans

Last updated Oct 26, 2026 Cloudflare Workers automatically instruments platform operations like fetch calls, KV reads, and D1 queries. Custom spans let you extend this visibility into your own application logic, so you can trace custom code paths alongside the built-in instrumentation.

The custom spans API is available in two ways — both provide the same methods and behave identically:

  • import { tracing } from "cloudflare:workers" — works anywhere in your codebase, including utility functions, libraries, and modules that do not have access to the handler context.
  • ctx.tracing — available on the ExecutionContext passed to your handler, convenient when you are already working within a handler.

There are three span creation methods:

  • enterSpan() — creates a span that automatically ends when the callback returns or its returned promise settles. Use this for most instrumentation.
  • startActiveSpan() — creates a span that is active during a callback, and that you end manually by calling span.end(). Use this when the span must outlive the callback, such as when instrumenting streams or other long-lived operations.
  • startSpan() — creates a span without making it active, and that you end manually by calling span.end(). Use this to time an operation when you do not need other spans to nest under it.

You can also call getActiveSpan() to get the currently active span, so you can add attributes or record exceptions without passing the span object through your code.

Enable tracing

Custom spans require tracing to be enabled on your Worker. If you have not already done so, set observability.traces.enabled to true in your Wrangler configuration file:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "observability": {
    "traces": {
      "enabled": true
    }
  }
}
[observability.traces]
enabled = true

Create a custom span

Use tracing.enterSpan() to wrap a section of code in a named span. The span automatically becomes a child of whichever span is currently active, and ends when the callback returns or its returned promise settles.

The following example uses both access methods — the cloudflare:workers import and ctx.tracing — to show that they are interchangeable:

src/index.jsjs

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request, env, ctx) {
        // Using the import
        return tracing.enterSpan("handleRequest", async (span) => {
            span.setAttribute("url.path", new URL(request.url).pathname);

const user = await ctx.tracing.enterSpan("auth", async () => {
                // Using ctx.tracing
                return authenticate(request, env);
            });

return buildResponse(user);
        });
    },
};

src/index.tsts

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext) {
        // Using the import
        return tracing.enterSpan("handleRequest", async (span) => {
            span.setAttribute("url.path", new URL(request.url).pathname);

const user = await ctx.tracing.enterSpan("auth", async () => {
                // Using ctx.tracing
                return authenticate(request, env);
            });

return buildResponse(user);
        });
    },
};

API reference

tracing.enterSpan(name, callback, ...args)

Creates a new span and runs callback inside it. The span is automatically ended when the callback returns (synchronous or asynchronous) or throws.

Parameters:

Parameter Type Description
name string The name of the span. This appears in trace visualizations.
callback (span: Span, ...args: A) => T The function to execute within the span. Receives the Span object as its first argument, followed by any additional arguments passed to enterSpan.
...args A Optional additional arguments forwarded to the callback after the span parameter.

Returns: The return value of callback.

Behavior:

  • The new span is a child of whichever span is currently active on the async context. If no span is active, it becomes a child of the request's root span.
  • Nested enterSpan calls and runtime-created spans (such as fetch or KV operations) that run inside the callback automatically become children of this span.
  • The span ends when the callback returns synchronously, throws synchronously, or when its returned promise fulfills or rejects.
// Synchronous callback — span ends when the function returns
const result = tracing.enterSpan("parse", (span) => {
    span.setAttribute("format", "json");
    return JSON.parse(body);
});

// Async callback — span ends when the promise settles
const data = await tracing.enterSpan("fetchData", async (span) => {
    const res = await fetch("https://api.example.com/data");
    span.setAttribute("http.response.status_code", res.status);
    return res.json();
});

// Forwarding arguments
const doubled = tracing.enterSpan("compute", (span, x) => x * 2, 21);

tracing.startActiveSpan(name, callback, ...args)

Creates a new span, makes it the active span while callback runs, and returns the callback result without automatically ending the span. You must call span.end() explicitly when the operation is complete.

Parameters:

Parameter Type Description
name string The name of the span. This appears in trace visualizations.
callback (span: Span, ...args: A) => T The function to execute while the span is active. Receives the Span object as its first argument, followed by any additional arguments.
...args A Optional additional arguments forwarded to the callback after the span parameter.

Returns: The return value of callback.

Behavior:

  • Unlike enterSpan, the span is not automatically ended when the callback returns or throws. You are responsible for calling span.end().
  • If you forget to call span.end(), the span is still submitted when the request-owned span object is destroyed, as a backstop. Do not rely on this behavior — always call span.end() explicitly.

Caution

startActiveSpan gives you manual lifetime management, but only in an "active during callback" shape. The span is the active context parent during the callback, so any child spans or platform operations created inside the callback are correctly nested. After the callback returns, the span is no longer the active parent, even though it remains open. This means you cannot create child spans of a startActiveSpan span from outside the callback.

Use startActiveSpan when you need a span to cover an operation that extends beyond a single callback — for example, instrumenting a stream pipeline where the span should remain open until the stream is fully consumed:

src/index.jsjs

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request, env, ctx) {
        const body = request.body;
        if (!body) return new Response("No body", { status: 400 });

// The span is active during the callback, so the pipeThrough
        // operation is correctly nested. The span stays open after
        // the callback returns, until flush() calls span.end().
        const stream = tracing.startActiveSpan("process-stream", (span) => {
            span.setAttribute(
                "request.content_type",
                request.headers.get("content-type") ?? "unknown",
            );

return body.pipeThrough(
                new TransformStream({
                    transform(chunk, controller) {
                        // Process each chunk
                        controller.enqueue(chunk);
                    },
                    flush() {
                        span.setAttribute("stream.status", "complete");
                        span.end();
                    },
                    cancel() {
                        span.setAttribute("stream.status", "cancelled");
                        span.end();
                    },
                }),
            );
        });

return new Response(stream);
    },
};

src/index.tsts

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext) {
        const body = request.body;
        if (!body) return new Response("No body", { status: 400 });

// The span is active during the callback, so the pipeThrough
        // operation is correctly nested. The span stays open after
        // the callback returns, until flush() calls span.end().
        const stream = tracing.startActiveSpan("process-stream", (span) => {
            span.setAttribute(
                "request.content_type",
                request.headers.get("content-type") ?? "unknown",
            );

return body.pipeThrough(
                new TransformStream({
                    transform(chunk, controller) {
                        // Process each chunk
                        controller.enqueue(chunk);
                    },
                    flush() {
                        span.setAttribute("stream.status", "complete");
                        span.end();
                    },
                    cancel() {
                        span.setAttribute("stream.status", "cancelled");
                        span.end();
                    },
                }),
            );
        });

return new Response(stream);
    },
};

If you do not need the span to be active during a callback, use startSpan instead.

tracing.startSpan(name)

Creates a new span and returns it without making it the active span. You must call span.end() explicitly when the operation is complete.

Parameters:

Parameter Type Description
name string The name of the span. This appears in trace visualizations.

Returns: A Span.

Behavior:

  • The new span is a child of whichever span is currently active on the async context. If no span is active, it becomes a child of the request's root span.
  • The span never becomes the active span. Spans created by enterSpan, startActiveSpan, and platform operations (such as fetch or KV operations) that run while it is open are not children of this span. They are siblings.
  • If you forget to call span.end(), the span is still submitted when the request-owned span object is destroyed, as a backstop. Do not rely on this behavior — always call span.end() explicitly.

Use startSpan when you want to measure an operation whose start and end happen in different places, such as in event callbacks or across the methods of a class, and you do not need other spans nested under it:

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext) {
        // ...
        const span = tracing.startSpan("cache-warmup");
        span.setAttribute("cache.keys", keys.length);

try {
            await warmCache(keys);
            span.setAttribute("cache.warmup.status", "complete");
        } catch (err) {
            span.recordException(err as Error);
            throw err;
        } finally {
            span.end();
        }
        // ...
    },
};

tracing.getActiveSpan()

Returns the span that is currently active on the async context.

Returns: A Span, or undefined.

Behavior:

  • Inside a request, returns the Span that is currently considered active. If no spans have been explicitly created with enterSpan or startActiveSpan or the system itself, it will return the root span of the current invocation. You can use it to add attributes or record exceptions on the root span.
  • Returns undefined outside a request, such as in the top-level scope of your module, or in code that runs in an async context captured outside a request.
  • Spans created with startSpan never become active, so getActiveSpan never returns them.

Use getActiveSpan to annotate the current span without passing the span object through your code:

src/index.jsjs

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request, env, ctx) {
        const user = await authenticate(request, env);

// No custom span is active, so this annotates the root span
        tracing.getActiveSpan()?.setAttributes({
            "user.id": user.id,
            "user.plan": user.plan,
        });

return tracing.enterSpan("render", async () => {
            // ...
            return new Response("OK");
        });
    },
};

src/index.tsts

import { tracing } from "cloudflare:workers";

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext) {
        const user = await authenticate(request, env);

// No custom span is active, so this annotates the root span
        tracing.getActiveSpan()?.setAttributes({
            "user.id": user.id,
            "user.plan": user.plan,
        });

return tracing.enterSpan("render", async () => {
            // ...
            return new Response("OK");
        });
    },
};

Span

enterSpan and startActiveSpan pass a Span object to their callbacks, and startSpan and getActiveSpan return one. It provides methods to annotate the span with metadata and control its lifecycle.

span.setAttribute(key, value)

Sets an attribute on the span.

Parameter Type Description
key string The attribute name.
value string \| number \| boolean \| undefined The attribute value. Passing undefined is a no-op.

Returns: The same Span, so you can chain calls.

Attributes appear alongside the span in your traces and OpenTelemetry exports.

span
    .setAttribute("user.plan", "enterprise")
    .setAttribute("item.count", 42)
    .setAttribute("cache.hit", true);

span.setAttributes(attributes)

Sets multiple attributes on the span at once. This works the same as calling setAttribute for each entry.

Parameter Type Description
attributes Record<string, string \| number \| boolean \| undefined> An object that maps attribute names to values. Entries with undefined values are ignored.

Returns: The same Span, so you can chain calls.

span.setAttributes({
    "user.plan": "enterprise",
    "item.count": 42,
    "cache.hit": true,
    "optional.field": undefined, // ignored
});

span.recordException(exception)

Records an exception event on the span. The event includes a timestamp and appears in your traces and OpenTelemetry exports.

Parameter Type Description
exception Exception The exception to record.

You can pass any of the following:

  • An Error object. The runtime records its name, message, and stack.
  • A string, which the runtime records as the exception message.
  • An object with at least one of code, name, or message, and an optional stack. code can be a string or a number.

The runtime ignores the call if the span is not being traced or has already ended, or if you pass an object that has none of code, name, or message.

Recording an exception does not end the span or change how it ends. It also does not catch or rethrow the error. You still handle the error in your own code.

await tracing.startActiveSpan("chargeCard", async (span) => {
    try {
        return await chargeCard(token, amount);
    } catch (err) {
        span.recordException(err as Error);
        throw err;
    } finally {
        span.end();
    }
});
// Strings and plain objects are also accepted
span.recordException("Upstream returned an empty body");
span.recordException({ code: "RATE_LIMITED", message: "Too many requests" });

span.setStatus(status)

Sets the status of the span. Span status follows the OpenTelemetry span status ↗︎ model. Use it to mark an operation as failed or explicitly successful.

Parameter Type Description
status SpanStatus The status to set on the span.

SpanStatus is an object with the following fields:

Field Type Description
code "unset" \| "ok" \| "error" The status code. Every span starts as "unset".
message string (optional) A developer-facing description of the error. Only kept when code is "error".

Returns: The same Span, so you can chain calls.

Behavior:

  • "ok" is final. After you set a span's status to "ok", later setStatus calls on that span are ignored.
  • Setting "unset" has no effect. It does not clear an earlier "error" status.
  • message is dropped unless code is "error".
  • Calls after the span has ended are ignored.
  • Any other code value throws a TypeError. Codes are lowercase, so "OK" is not valid.
  • Setting the status does not end the span or record an exception. To record the error details as well, call recordException.