Skip to content

Unit-of-Work Telemetry

Emit one rich, structured log entry per unit of work. Instead of scattering dozens of log calls throughout handler code, accumulate context as the work progresses and emit a single rich log entry when it completes. Also known as canonical log lines (Stripe), structured events (Honeycomb), wide events, request-scoped events.

Wrap functions with log.traced() to create a span — it accumulates structured context, records timestamped events, and emits one log entry when the function completes. Spans propagate via AsyncLocalStorage with automatic parent–child relationships, so no function ever threads context manually.

When to use

Use this pattern at any boundary where a unit of work starts and ends — HTTP requests, queue jobs, scheduled tasks, RPC calls — and you want full context in one place instead of scattered console.log calls. The structured output is searchable and queryable in any log aggregator (Cloud Logging, Datadog, ELK).

Use log.traced() on functions that represent meaningful operations: service-layer methods, external calls, business logic with branching outcomes. Don't trace utility functions, getters, or pure transforms — those can still contribute context to the parent span via log.currentSpan() without producing their own log entry.

The pattern

Accumulating context during a unit of work

Wrap a function with log.traced(). Inside, call span.set() to accumulate structured fields and span.event() to record timestamped events. When the function returns (or throws), the span emits everything as one log entry:

javascript
import createLogger from "./logger.js";

const log = createLogger({ service: "jobs" });

const processJob = log.traced(
  "processJob()",
  async (job) => {
    const span = log.currentSpan();
    span.set("job.type", job.type);
    span.set("job.id", job.id);
    
    const items = await fetchItems(job);
    span.set("items.count", items.length);
    span.event("Fetched items");
    
    const results = await transform(items);
    span.set("results.count", results.length);
    span.event("Transformed items");
    
    await store(results);
    span.event("Stored results");
  },
);

// Call it like any normal function
await processJob(job);

Error handling is automatic — traced() catches exceptions, sets status to "error" with error.type and error.message, then re-throws. The emitted log entry contains every field accumulated during processing, plus span metadata and timing:

json
{
  "span.name": "processJob()",
  "span.id": "a1b2c3",
  "trace.id": "x9y8z7",
  "job.type": "import",
  "job.id": "abc-123",
  "items.count": 42,
  "results.count": 42,
  "status": "ok",
  "duration_ms": 817,
  "events": [
    {
      "ms": 12,
      "time": "2025-06-15T14:30:00.012Z",
      "message": "Fetched items"
    },
    {
      "ms": 415,
      "time": "2025-06-15T14:30:00.415Z",
      "message": "Transformed items"
    },
    {
      "ms": 816,
      "time": "2025-06-15T14:30:00.816Z",
      "message": "Stored results"
    }
  ],
  "severity": "DEBUG"
}

Contributing context without tracing

Not every function needs its own span. Helpers and utilities can get a handle on whatever span is currently active and add context to it:

javascript
import createLogger from "./logger.js";

const log = createLogger({ module: "email" });

async function validateEmail(addr) {
  const span = log.currentSpan();
  const result = await checkMx(addr);
  span.set("email.mx_valid", result.valid);
  span.event("MX lookup complete");
  return result;
}

If validateEmail is called inside createUser, the email.mx_valid field appears in createUser's log entry.

If there's no active span, currentSpan() warns and returns a detached span — one with no parent that is not a root. The warning and the span both include an async stack trace (span.detached_stack) showing exactly where the missing traced() or startSpan() wrapper should be. The span still accumulates normally and emits when end() is called. If end() is never called, the span emits itself during garbage collection via a FinalizationRegistry. Data is never lost.

Automatic parent–child relationships

When traced functions call other traced functions, the parent–child relationship is captured automatically:

javascript
const sendWelcome = log.traced("sendWelcome()", async (user) => {
  const span = log.currentSpan();
  const template = await renderTemplate("welcome", user);
  span.set("template.size", template.length);
  
  await smtp.send({ to: user.email, body: template });
  span.set("email.sent", true);
});

const createUser = log.traced("createUser()", async (input) => {
  const span = log.currentSpan();
  const user = await db.insert(input);
  await sendWelcome(user);  // gets its own span, linked to parent
  return user;
});

Emits two entries that share a trace.id:

trace.id: "x9y8z7"  span.name: "createUser()"    parent.span.id: null
trace.id: "x9y8z7"  span.name: "sendWelcome()"  parent.span.id: "a1b2c3"

No function passes IDs to another. AsyncLocalStorage handles it.

Concurrent children (Promise.all)

Parenting works correctly with concurrent execution. Each traced() call captures its parent before entering its own AsyncLocalStorage context, so concurrent children all link to the right parent:

javascript
const createUser = log.traced("createUser()", async (input) => {
  const span = log.currentSpan();
  const user = await db.insert(input);
  
  await Promise.all([
    sendWelcome(user),      // parent.span.id → createUser()
    syncToAnalytics(user),  // parent.span.id → createUser()
  ]);
  
  return user;
});

If concurrent branches spawn their own traced children, the nesting continues correctly — each AsyncLocalStorage run creates an isolated context.

Untraced functions calling currentSpan() during concurrent execution share the parent span. Interleaved event() calls are fine — the timestamps make the ordering clear. Key collisions from concurrent set() calls are caught automatically: the span records the overwrite in its events with the old and new values, so no data is silently lost.

The best practice is to use namespaced keys — email.mx_valid, phone.valid — so concurrent branches don't collide. The overwrite warning is a safety net, not something you should see regularly.

Request-level spans

At the HTTP boundary, use log.startSpan() to create a root span. Unlike traced(), startSpan() runs its callback immediately and gives you the span directly — you manage the lifecycle by calling span.end() yourself:

javascript
import createLogger from "./logger.js";

const log = createLogger({ service: "api" });

app.use((req, res, next) => {
  const traceId = req.headers["x-trace-id"] ?? crypto.randomUUID();
  
  const name = `${req.method} ${req.route?.path ?? req.path}`;
  log.startSpan(name, { traceId }, (span) => {
    span.set("http.request.method", req.method);
    span.set("http.request.path", req.path);
    
    res.on("finish", () => {
      span.set("http.response.status_code", res.statusCode);
      span.end();
    });
    
    next();
  });
});

Every traced() function called during request handling automatically becomes a child of the request span. The request-level span works the same way — it accumulates context and emits one rich log entry at the end. See request logging middleware for the Express-specific implementation.

Implementation

Core: span creation and AsyncLocalStorage

javascript
import { AsyncLocalStorage } from "node:async_hooks";
import { performance } from "node:perf_hooks";
import crypto from "node:crypto";

const store = new AsyncLocalStorage();
const release = new FinalizationRegistry((span) => {
  if (!span.ended) {
    span.set("span.gc_emitted", true);
    span.end();
  }
});

function createSpan(name, options = {}) {
  const { logger, level = "debug" } = options;
  const parent = store.getStore();
  const id = crypto.randomUUID().slice(0, 8);
  const traceId =
    options.traceId ??
    parent?.traceId ??
    crypto.randomUUID().slice(0, 8);
  const startTime = new Date();
  const startPerf = performance.now();
  const context = new Map([
    ["span.name", name],
    ["span.id", id],
    ["trace.id", traceId],
  ]);
  const events = [];
  
  if (parent) {
    context.set("parent.span.id", parent.id);
  }
  
  function elapsed() {
    return Math.round(performance.now() - startPerf);
  }
  
  const span = {
    id,
    traceId,
    ended: false,
    
    set(key, value) {
      if (context.has(key)) {
        const prev = context.get(key);
        events.push({
          ms: elapsed(),
          time: new Date().toISOString(),
          message: `key "${key}" overwritten`,
          "overwrite.previous": prev,
          "overwrite.new": value,
        });
      }
      context.set(key, value);
    },
    
    event(message, attributes = {}) {
      events.push({
        ms: elapsed(),
        time: new Date().toISOString(),
        message,
        ...attributes,
      });
    },
    
    end() {
      if (span.ended) return;
      span.ended = true;
      const durationMs = elapsed();
      context.set("duration_ms", durationMs);
      context.set("end_time", new Date().toISOString());
      
      if (events.length) {
        context.set("events", events);
      }
      
      const entry = Object.fromEntries(context);
      const emitLevel =
        context.get("status") === "error" ?
          "error" : level;
      
      logger[emitLevel]({
        ...entry,
        time: startTime.getTime(),
      });
    },
  };
  
  release.register(span, span);
  return span;
}

Adding tracing to the logger

traced(), startSpan(), and currentSpan() are methods on the logger. Each creates spans that are bound to the logger that created them — span.end() emits via the right logger automatically:

javascript
function addTracing(logger) {
  logger.traced = function (name, fn, options = {}) {
    const { level = "debug" } = options;
    
    return async function (...args) {
      const span = createSpan(name, { logger, level });
      
      return store.run(span, async () => {
        try {
          const result = await fn.apply(this, args);
          span.set("status", "ok");
          return result;
        } catch (error) {
          span.set("status", "error");
          span.set("error.type", error.name);
          span.set("error.message", error.message);
          throw error;
        } finally {
          span.end();
        }
      });
    };
  };
  
  logger.startSpan = function (name, options, fn) {
    if (typeof options === "function") {
      fn = options;
      options = {};
    }
    
    const span = createSpan(name, {
      ...options,
      logger,
    });
    
    return store.run(span, () => fn(span));
  };
  
  return logger;
}

log.currentSpan()

Returns the active span from AsyncLocalStorage. This is a global lookup — any logger instance returns the same span. Add it inside addTracing():

javascript
logger.currentSpan = function () {
  const span = store.getStore();
  if (span) return span;
  
  const stack = new Error("detached span").stack;
  logger.warn("currentSpan() called with no active span", { stack });
  const detached = createSpan("(detached)", { logger });
  detached.set("span.detached", true);
  detached.set("span.detached_stack", stack);
  return detached;
};

Integrating with createLogger

Call addTracing() when building the logger. See custom logger for the full createLogger implementation:

javascript
export default function createLogger(options) {
  const logger = pino(options);
  return addTracing(logger);
}

Timestamp strategy

Set the log entry's timestamp to when the span started, not when it was emitted. Include end_time and duration_ms in the payload.

When reading a trace sorted by timestamp, you want entries in the order operations began. If a parent starts at T=0 and a child starts at T=50, the parent should appear first — even though the child finishes and emits before the parent does.

Events carry both a relative ms (for quick scanning within a span) and an absolute time (for correlating across spans).

With pino, override the time field at emit time:

javascript
logger[level]({ ...entry, time: startTime.getTime() });

Naming conventions

Span names

Name spans after what they trace. A developer who knows the codebase should instantly recognize what each span represents:

  • Functions: "createUser()", "processJob()", "sendWelcome()"
  • HTTP requests: "GET /users/:id", "POST /orders"
  • Messaging: "publish order.created", "consume email.send"

Field names

Use OpenTelemetry semantic conventions for field names. This is free — it's just strings — and it means your logs are interoperable with tracing tools and queryable by anyone who knows the conventions:

FieldExampleNotes
span.name"createUser()"Reads like what it traces
span.id"a1b2c3"Unique per span
trace.id"x9y8z7"Shared across a full request
parent.span.id"f4e5d6"Null for root spans
http.request.method"POST"OTel convention
http.response.status_code201OTel convention
db.system"postgresql"OTel convention
error.type"ValidationError"OTel convention
error.message"email is required"Human-readable
duration_ms247Span duration
end_time"2025-..."ISO 8601
status"ok" or "error"Outcome
events[{ ms, time, message }]Timestamped events within the span

Use dotted names for application-specific fields too: user.id, job.type, items.count. Dots create natural grouping in log explorers.

Testing

Pass a capture stream to the logger to assert on emitted entries without touching stdout. See custom logger for the captureStream pattern:

javascript
import { Writable } from "node:stream";
import createLogger from "./logger.js";

function captureStream() {
  const lines = [];
  const stream = new Writable({
    write(chunk, encoding, cb) {
      lines.push(JSON.parse(chunk.toString()));
      cb();
    },
  });
  return { lines, stream };
}

const { lines, stream } = captureStream();
const log = createLogger({
  destination: stream,
  level: "trace",
});

const myFn = log.traced("myFn()", async () => {
  const span = log.currentSpan();
  span.set("key", "value");
  span.event("something happened", { detail: 42 });
});

await myFn();

assert.equal(lines.length, 1);
assert.equal(lines[0]["span.name"], "myFn()");
assert.equal(lines[0].key, "value");
assert.equal(lines[0].status, "ok");
assert.ok(lines[0].duration_ms >= 0);
assert.ok(lines[0]["trace.id"]);
assert.equal(lines[0].events.length, 1);
assert.equal(
  lines[0].events[0].message,
  "something happened",
);
assert.equal(lines[0].events[0].detail, 42);

Design principles

Use set(key, value), not positional args. A Map preserves insertion order and makes field names explicit. Positional APIs like context.$log("key", value) lose the structure when serialized.

Align field names with OpenTelemetry. Use dotted names like http.request.method, enduser.id, and error.type so logs are interoperable with tracing tools and aggregators.

Emit at the boundary, not inside. The log entry is emitted once when the unit of work completes. Intermediate event() calls only add timestamped events to the entry — they do not produce separate log output.

The logger creates the span. Since the logger already knows about severity, formatting, and destination, it's the natural factory for spans. log.traced() and log.startSpan() create spans that emit via the logger that created them — no wiring needed.

The span is one object. set(), event(), and end() are methods on the span. There are no free-floating helper functions — context and events belong to the span they're attached to.

log.traced() is a decorator, log.startSpan() is immediate. traced() returns a wrapped function — the span lifecycle is automatic. startSpan() runs a callback now and gives you the span — you call end() yourself. Different names because they do different things.

Any function can reach the current span. log.currentSpan() returns the active span from AsyncLocalStorage. If there is no active span, it warns and returns a detached span marked span.detached: true. Telemetry never throws.

Parent–child relationships are automatic. AsyncLocalStorage nesting handles it. No function passes span IDs to another.

Emit at completion, timestamp at start. Each span emits one log entry when the function returns or throws. The entry's timestamp is when the function was called, so traces read in chronological order.

Key overwrites are recorded, not silent. If set() is called with a key that already exists, the previous value is captured in an event before being overwritten. No data is lost.

Default to debug, promote selectively. Most function-level traces are too granular for info. Promote specific high-value operations:

javascript
const processPayment = log.traced("processPayment()", fn, { level: "info" });

Trade-offs

Benefits:

  • One queryable line per unit of work — easy to search and filter
  • Full context in a single entry — no correlating scattered log lines
  • Structured data — machine-readable, dashboardable
  • Errors include the full execution context that led to the failure
  • Automatic trace and parent IDs without manual wiring
  • Structured events with absolute timestamps — correlatable across spans, convertible to OTel events
  • Start-time timestamps give correct chronological ordering
  • OTel-compatible field names — interoperable with tracing tools
  • Compatible with the custom logger pattern for severity mapping and pretty printing

Costs:

  • Delayed visibility — nothing is logged until the work completes (or fails)
  • Memory — context grows with events for the duration of the work
  • Discipline — every code path must flow through the accumulator to be captured
  • AsyncLocalStorage — minor per-run overhead (negligible for I/O-bound work, measurable in tight CPU loops)
  • No automatic library instrumentation — you must explicitly trace or use log.currentSpan() for third-party calls (unlike OpenTelemetry's monkeypatching)

When to prefer scattered logs instead:

  • Long-running operations where you need real-time visibility
  • Streaming responses where the "end" boundary is ambiguous
  • Development/debugging where immediate console output is more useful than structured entries

When to reach for OpenTelemetry instead:

  • You need automatic instrumentation of database drivers, HTTP clients, and other libraries without wrapping them
  • You have multiple services and need cross-service trace propagation with standard tooling
  • You want a tracing UI with waterfall visualizations out of the box
  • The OTel configuration overhead is acceptable for your team size

Migration path:

Your log entries already contain trace.id, span.id, parent.span.id, start time, duration, and structured events — everything a tracing backend needs. A simple adapter can convert these log entries into OTel spans for export to Cloud Trace, Jaeger, or similar tools. The application code doesn't change; it's a read-time transformation.