Skip to content

feat: AsyncLocalStorage-backed active span context #4

@stackbilt-admin

Description

@stackbilt-admin

Context

Second of three ergonomics issues surfaced while dogfooding on Stackbilt-dev/tarotscript#163. Sibling: #3 (Span.startChild + Hono middleware).

Pain point

Child spans require access to a parent span. Today, the parent has to be threaded through every function call that wants to create a child. This works for shallow handlers but fails catastrophically for deep instrumentation.

Concrete example from tarotscript

The `POST /run` handler wants child spans on:

  • `scaffold.classify` (in the handler itself — easy)
  • `tarotscript.interpret` (calls `run(source)` which enters the TarotScript interpreter)
  • `scaffold.materialize` (calls `materializeScaffold(facts)`)
  • Oracle inference calls (fire inside the interpreter via an injected function)

The interpreter lives in `src/interpreter/interpreter.ts`, 5 call-stack layers deep from the handler. To create an "oracle.inference" child span at that depth, I'd have to:

  1. Thread `parentSpan` through `run()` → `interpret()` → `executeDraw()` → `oracleCall()` → `inferenceFn()`
  2. Change ~8 function signatures in the interpreter for observability purposes
  3. Couple the language runtime to the observability library

That's a hard no — language stays pure.

Proposed ergonomic: AsyncLocalStorage active span

```ts
import { AsyncLocalStorage } from 'node:async_hooks';
// (Workers runtime supports AsyncLocalStorage since ~2023; compatibility flag 'nodejs_als')

// Library internals
const activeSpanStorage = new AsyncLocalStorage();

export class Tracer {
runWithSpan(span: Span, fn: () => T): T {
return activeSpanStorage.run(span, fn);
}
}

export function getActiveSpan(): Span | undefined {
return activeSpanStorage.getStore();
}
```

Usage from the Hono middleware

```ts
app.use('*', honoTracing(obs, {
// middleware wraps `next()` in tracer.runWithSpan(rootSpan, ...) automatically
}));
```

Usage from deep code (no threading)

```ts
// Inside the interpreter's oracle call, 5 layers down:
import { getActiveSpan } from '@stackbilt/worker-observability';

async function inferenceFn(prompt: string) {
const span = getActiveSpan()?.startChild('oracle.inference', {
'oracle.provider': 'cerebras',
'oracle.prompt_tokens': estimateTokens(prompt),
});
try {
const result = await providers.complete(prompt);
span?.setAttributes({ 'oracle.completion_tokens': result.usage.completion_tokens });
return result;
} finally {
span?.end();
}
}
```

The interpreter knows nothing about the root span. The observability library handles parent resolution via ALS. The language stays pure.

Why this matters for the platform

This is the scalability lever for deep instrumentation. Without it:

  • Library users can only instrument shallow handler code
  • Dashboards show incomplete waterfalls (interpret phase is a black box)
  • Third-party customer workers that want to instrument their own code hit the same wall

With it:

  • `getActiveSpan()` becomes the universal "I'm inside a traced request, add a span" pattern
  • Any library (TarotScript interpreter, LLM providers, scaffold materializer) can optionally create spans without taking a dependency on observability — just import `getActiveSpan` as an optional peer and no-op when it returns undefined
  • Customer workers can instrument their own deep code without threading context

Caveats

  • Cloudflare Workers compatibility: AsyncLocalStorage requires the `nodejs_als` compat flag in `wrangler.toml`. The library should document this requirement and degrade gracefully (return `undefined` from `getActiveSpan()`) when ALS is not available
  • Memory: Spans held in ALS for the duration of the request — negligible for normal request lifetimes
  • Testing: ALS semantics need test coverage for nested spans, async boundaries, error paths

Related

  • ergonomics: Span.startChild() + Hono-aware middleware helper #3 — Span.startChild() + Hono middleware helper (builds the foundation this issue depends on)
  • Quickstart docs issue (will file separately)
  • Stackbilt-dev/tarotscript#163 — first dogfood target, hit this friction point when considering interpreter-level instrumentation

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions