docs

Background spans

What Unhandled flags as a background span, where you see the flag, and how to attach that work to a request, job or command.

A background span is a CLIENT span from a backend service that has no parent: a database query, cache call or outgoing HTTP request that did not run inside any request, job or command. Unhandled flags these spans because each one shows up as a trace of its own, without the context you need to understand it. This page explains how they are detected and how to fix the common causes.

How Unhandled detects them

A span is a background span when all three hold:

  • it has no parent span,
  • its kind is CLIENT,
  • its service is not a browser service. Browser services are the ones whose resource has telemetry.sdk.language=webjs, as the browser SDK sends.

Browser network spans are excluded on purpose. In a browser, a user action that calls fetch legitimately starts a trace.

Other root spans are not flagged. A job dispatched outside any request, for example by a scheduler, starts with a PRODUCER span and appears as a Queue entry point. A job execution starts with a CONSUMER span and appears as a Job. Neither is a background span.

Where you see them

  • Trace waterfall. The span carries a bg badge, and the trace's entry point reads BG with the label "Background span".
  • Traces list. The Hide background spans checkbox removes them from the list and the chart.
  • Search. The span search accepts the field background. Use background:true to list them and background:false to exclude them.
  • MCP server. The list_traces tool takes the same search syntax, so an assistant connected to the MCP server can run background:true over a time window.

Why it matters

A handful of background spans is normal, for example the queries your app runs while it boots. Many of them usually mean context is lost somewhere:

  • Queries and HTTP calls are not attached to the request or job that caused them, so slow endpoints look faster than they are.
  • The traces list fills with single-span traces.
  • Exceptions raised in that work carry no route, job or user.

Find the source

  1. Turn on background spans only, with background:true in the traces search.
  2. Group or sort by span name. A few names usually account for most of them.
  3. Open one trace and read the span's attributes. db.query.text, url.full or cache.key tells you which code path issued it.
  4. Find that call site and check which of the causes below applies.

Cause 1: work outside a request

Timers, polling loops, setInterval callbacks and hand-rolled workers run outside any request or job. Every query they make becomes a root span. Wrap each iteration in a span so the queries become its children.

With the AdonisJS agent, use record():

ts
import { record } from '@unhandled/agent-adonisjs'
import db from '@adonisjs/lucid/services/db'

setInterval(() => {
  void record('orders.expire_unpaid', async () => {
    await db
      .from('orders')
      .where('status', 'pending')
      .where('created_at', '<', new Date(Date.now() - 30 * 60_000))
      .update({ status: 'expired' })
  })
}, 60_000)

record() starts an active span, ends it when the callback returns or its promise settles, and records the exception if the callback throws. With another OpenTelemetry SDK, tracer.startActiveSpan() from @opentelemetry/api does the same job. For recurring work that belongs in a queue, dispatching a job gives you job spans for free.

Cause 2: context lost across an async boundary

OpenTelemetry tracks the active span through async calls. Some patterns break that chain: callbacks stored and run later by a long-lived object, connection pools that call back from their own context, and custom thenables. The code runs with the context of whoever drains the queue, often none, so its queries become roots.

A typical case is an in-process buffer drained by a timer started at boot. Bind each task to the context that was active when it was queued:

ts
import { context } from '@opentelemetry/api'

type Task = () => Promise<void>

const pending: Task[] = []

setInterval(() => {
  for (const task of pending.splice(0)) void task()
}, 1000)

export const later = (task: Task) => {
  pending.push(context.bind(context.active(), task))
}

Without context.bind, a later(() => sendReceipt(order)) call made during a request runs in the timer's empty context. To run a block of code in a context you captured earlier, use context.with(captured, () => ...).

Cause 3: instrumentation loaded too late

Auto-instrumentations patch modules as they are imported. If your database driver or HTTP client loads before OpenTelemetry starts, its calls are not traced at all, or are traced without the server span that should be their parent. Start OpenTelemetry first, for example with node --import ./otel.js server.js. The AdonisJS agent sets up the right load order for you.

Cause 4: noise you do not want

Health checks against the database, tight polling loops and keep-alive pings produce spans with no value. Suppress tracing for that code instead of wrapping it:

ts
import { context } from '@opentelemetry/api'
import { suppressTracing } from '@opentelemetry/core'
import db from '@adonisjs/lucid/services/db'

export const databaseIsUp = () =>
  context.with(suppressTracing(context.active()), async () => {
    await db.rawQuery('select 1')
    return true
  })

Spans started inside suppressTracing are not recorded.