setInterval, cron jobs, worker-pool callbacks, EventEmitter handlers. This guide explains how the Atatus Node.js APM agent tracks transactions across such async boundaries, and the recommended patterns for instrumenting each common shape so that spans are captured under the correct transaction.
How transaction context propagates
The Node.js agent uses Node’sAsyncLocalStorage to track the active transaction across asynchronous boundaries. When you call atatus.startTransaction(...) (or when an auto-instrumented entry point such as an HTTP request or Kafka message starts one for you), the agent associates that transaction with the current async resource. Awaited code, promise continuations, and timers scheduled while the transaction is active automatically inherit the same context, and any auto-instrumented operation (Mongo, Redis, PostgreSQL, HTTP, etc.) that runs in that context becomes a child span of the transaction.
Two consequences follow from this model:
-
Async work scheduled while a transaction is active retains the active context, even if you do not
awaitit. AsetTimeout, a fire-and-forget promise, or a callback registered with a driver all capture the active transaction at scheduling time. When the work later runs, the agent still sees the transaction in scope. -
Async work scheduled when no transaction is active will not have one in scope when it runs. A
setIntervalset up at module load, a worker registered before any request, or a callback bound outside any handler will run in an empty context. The agent has nothing to attach spans to, soatatus.startSpan()will returnnull.
Pattern A: Non-awaited work inside a handler
Use this when a handler (HTTP route, Kafka consumer, queue consumer, etc.) needs to spawn background work that outlives the handler itself, for example a fire-and-forget database write you do not want to block on before committing the message offset. Recommended approach: pass the active transaction object to the background function as an explicit argument, and calltrans.startSpan(...) from inside it. The transaction reference is independent of the run context. Spans created through it link to the transaction by id, regardless of whether the handler has already returned.
Why this works
trans.startSpan(name, type, subtype, action)creates a span whose parent is the transaction itself (or the current span on this async resource, if one exists). The link is data: the span carries the transaction’s id, so the run context does not need to still contain the transaction at the moment you create the span.trans.startSpanalso marks the new span as the current span on the executing async resource. Any auto-instrumented client call (Mongo, Redis, HTTP, etc.) that runs while this span is active becomes a child of it automatically, so you do not need to pass the span object around.- The Atatus run context is immutable. Ending the transaction in the calling handler does not invalidate the transaction reference held by your background function.
Things to be aware of
- The transaction’s duration reflects only the synchronous portion of the handler. If the handler returns in 2 ms but the background work takes 10 s, the transaction shows as 2 ms in the dashboard and the spans appear with later timestamps under it. This is expected: the transaction represents the entry-point work, and the background spans represent operations that continued past it.
- The transaction object stays in memory until all child spans on it have ended. This is normally negligible, but for very high-throughput consumers (thousands of pending fire-and-forget tasks at once) it is worth measuring.
- Use
trans.startSpan, nottrans.createSpan.createSpanreturns the span without making it active, so auto-instrumented children inside the background function will not nest under it.
Pattern B: Background timers, intervals, and cron jobs
Use this when work runs on a periodic schedule independent of any incoming request or message, for example a buffered queue consumer that flushes every N seconds, asetInterval poller, or a cron job.
Recommended approach: start a new background transaction inside the callback, on every tick. Do not rely on a transaction created elsewhere. By the time the timer fires, no transaction is in scope.
Why this works
ThesetInterval is registered at module load (or first enqueue), long before any specific message arrives. Its callback runs in an empty context, so no transaction is active. By calling atatus.startBackgroundTransaction(...) as the very first thing inside flushBuffer, the agent installs a fresh transaction on the executing async resource, and every subsequent operation (the Mongo bulk write, any HTTP call, any custom span) is captured as a child of it.
Things to be aware of
- One transaction per flush, not per message. If a single flush writes 100 documents, you get one transaction with one Mongo span representing the bulk operation. This is usually what you want for batched I/O. If you need per-message visibility, instrument inside the per-message handler instead (Pattern A).
- Always end the transaction in
finally. A thrown error beforetrans.end()will leak the transaction reference and miss the flush in the dashboard. - Set the outcome.
trans.setOutcome("success" | "failure")drives the error-rate metrics for background transactions.
Pattern C: Worker pools and queue consumers with deferred work
Use this when the entry point of a unit of work is a callback registered before any specific request, for example abull/bullmq worker, a custom thread-pool callback, or a long-lived EventEmitter listener.
Recommended approach: treat each invocation of the callback as a fresh background transaction. The pattern is identical to Pattern B, but applied to a worker/listener rather than a timer.
trans.startSpan(...) inside the deferred function.
Choosing between patterns
Anti-patterns to avoid
Callingatatus.startSpan(...) from a context with no active transaction. It returns null and the operation is silently not recorded. This happens most often inside setInterval callbacks scheduled at startup, inside worker callbacks, or inside any code reached only through an emitter or driver hook registered outside a handler. Use Pattern B or C instead.
Ending a transaction before its background work has finished and expecting the spans to drop. They do not: they still link to the transaction by id and ship when the spans themselves end. This is by design (Pattern A relies on it). The only visual side effect is that the transaction’s duration reflects the handler, not the background tail.
Wrapping a long-lived listener in a single transaction. Starting a transaction once at registration time and never ending it makes the agent treat every subsequent operation as part of one very long transaction. Start a new background transaction per invocation instead.
Storing a transaction reference across unrelated callbacks. A transaction represents one logical unit of work. Reusing one across multiple independent worker invocations produces tangled traces. Create a fresh transaction per unit of work (Pattern B/C) and use Pattern A only for direct background work spawned by the same handler.