For the complete documentation index, see llms.txt. Markdown versions are available by appending .md to documentation URLs.

How to add manual instrumentation in Node.js

SigNoz Cloud - This page applies to SigNoz Cloud editions.
Self-Host - This page applies to self-hosted SigNoz editions.

Manual instrumentation lets you trace business operations that auto-instrumentation misses. Use it to capture checkout flows, payment processing, or any code path where you need visibility into what happened and why.

Prerequisites

Step 1. Create manual spans

Get a tracer and wrap operations in spans:

manual_span.js
const { trace } = require('@opentelemetry/api')
 
const tracer = trace.getTracer('order-service')
 
async function processOrder(orderId) {
  return tracer.startActiveSpan('process-order', async (span) => {
    try {
      span.setAttribute('order.id', orderId)
      span.setAttribute('order.status', 'processing')
 
      // Business logic here
      // Child spans in called functions link automatically
      await validateOrder(orderId)
 
      return { success: true }
    } finally {
      span.end()
    }
  })
}

For nested operations, child spans link to the parent automatically:

nested_spans.js
async function processOrder(orderId) {
  return tracer.startActiveSpan('process-order', async (parentSpan) => {
    try {
      parentSpan.setAttribute('order.id', orderId)
 
      // Child span tracks a sub-operation
      await tracer.startActiveSpan('validate-inventory', async (childSpan) => {
        try {
          childSpan.setAttribute('warehouse.id', 'WH-001')
          await checkInventory(orderId)
        } finally {
          childSpan.end()
        }
      })
 
    } finally {
      parentSpan.end()
    }
  })
}

Tips:

  • Reuse tracer instances instead of creating one per request.
  • Use descriptive span names that match business steps (checkout, fetch-user).
  • Always call span.end() in a finally block.

Step 2. Propagate context

Node.js keeps the active span in an AsyncLocalStorage context that the SDK registers for you. startActiveSpan links child spans through await chains, promises, and callbacks. Two things break that chain: a span you start without activating, and another service. See Context Propagation for the concepts behind this step.

Inside your process

startActiveSpan activates the span for the duration of its callback. startSpan creates the span without activating it, so nothing nested inside becomes its child:

in_process.js
// validateOrder's spans become children of process-order
await tracer.startActiveSpan('process-order', async (span) => {
  try {
    await validateOrder(orderId)
  } finally {
    span.end()
  }
})
 
// validateOrder's spans do NOT become children of process-order
const span = tracer.startSpan('process-order')
try {
  await validateOrder(orderId)
} finally {
  span.end()
}

To activate an existing span without the callback form, set it on a context and run your work inside context.with():

context_with.js
const { context, trace } = require('@opentelemetry/api')
 
const span = tracer.startSpan('process-order')
const ctx = trace.setSpan(context.active(), span)
 
try {
  await context.with(ctx, async () => {
    // Child spans created here attach to process-order
    await validateOrder(orderId)
  })
} finally {
  span.end()
}

Across a service boundary

@opentelemetry/instrumentation-http and the other instrumentation packages inject and extract for you. Write the calls yourself for any hop they do not cover.

Set the span kind on both ends. SigNoz reads Client and Server to build the service map and APM metrics.

propagate.js
const { context, propagation, SpanKind } = require('@opentelemetry/api')
 
// Outgoing: inject writes traceparent into the carrier
async function chargeCard(payload) {
  return tracer.startActiveSpan('charge-card', { kind: SpanKind.CLIENT }, async (span) => {
    try {
      const headers = { 'content-type': 'application/json' }
      propagation.inject(context.active(), headers)
 
      return await fetch('https://payments.internal/charge', {
        method: 'POST',
        headers,
        body: JSON.stringify(payload),
      })
    } finally {
      span.end()
    }
  })
}
 
// Incoming: extract returns a context to start the entry span in
async function handleCharge(req) {
  const activeContext = propagation.extract(context.active(), req.headers)
 
  return tracer.startActiveSpan('handle-charge', { kind: SpanKind.SERVER }, activeContext, async (span) => {
    try {
      // handle-charge is now a child of charge-card in the caller's trace
      await capturePayment(req.body)
    } finally {
      span.end()
    }
  })
}

The third argument to startActiveSpan is the parent context. Leave it out and the entry span takes whatever is active. At the edge of your service that is nothing, so the span starts a fresh trace.

For a queue, put the carrier on the message and read it back in the consumer:

queue.js
// Publisher
const carrier = {}
propagation.inject(context.active(), carrier)
await queue.send({ body: payload, attributes: carrier })
 
// Consumer
const ctx = propagation.extract(context.active(), message.attributes)
await tracer.startActiveSpan('process-message', {}, ctx, async (span) => {
  try {
    await handle(message)
  } finally {
    span.end()
  }
})

Step 3. Add attributes and events

Attributes let you filter and aggregate spans in SigNoz. Events mark notable moments within a span.

attributes.js
const { trace } = require('@opentelemetry/api')
 
function handlePayment(amount, currency = 'USD') {
  const span = trace.getActiveSpan()
  if (!span) return
 
  span.setAttribute('payment.amount', amount)
  span.setAttribute('payment.currency', currency)
  span.setAttribute('payment.method', 'credit_card')
 
  // Events mark milestones
  span.addEvent('payment.validated')
 
  // Process payment...
 
  span.addEvent('payment.processed', {
    'status': 'success',
    'transaction.id': 'txn_123456'
  })
}

For standard attribute names, use semantic conventions:

npm install @opentelemetry/semantic-conventions
semantic_attributes.js
const { trace } = require('@opentelemetry/api')
const { ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL } = require('@opentelemetry/semantic-conventions')
 
const span = trace.getActiveSpan()
if (span) {
  span.setAttribute(ATTR_HTTP_REQUEST_METHOD, 'GET')
  span.setAttribute(ATTR_URL_FULL, 'https://api.example.com/users')
}
  • Use semantic conventions when a standard attribute exists.
  • Add events for retries, cache hits/misses, queue waits.

Step 4. Record errors

Mark failures so they show up in SigNoz error views:

error_handling.js
const { trace, SpanStatusCode } = require('@opentelemetry/api')
 
async function riskyOperation() {
  return tracer.startActiveSpan('risky-operation', async (span) => {
    try {
      const result = await doSomethingRisky()
      span.setStatus({ code: SpanStatusCode.OK })
      return result
    } catch (error) {
      span.recordException(error)
      span.setStatus({ code: SpanStatusCode.ERROR, message: error.message })
      throw error
    } finally {
      span.end()
    }
  })
}
  • recordException attaches the stack trace.
  • SpanStatusCode.ERROR flags the span in SigNoz error views and alerts.
  • Re-throw so calling code can handle the failure.

Links connect causally related spans that aren't parent-child:

span_links.js
const { trace } = require('@opentelemetry/api')
 
const tracer = trace.getTracer('my-service')
 
// First operation - capture context
let linkContext
tracer.startActiveSpan('batch-job', (span) => {
  linkContext = span.spanContext()
  span.end()
})
 
// Later operation linked to the first
tracer.startActiveSpan('process-result', { links: [{ context: linkContext }] }, (span) => {
  // Linked to batch-job but not a child
  span.end()
})

Validate

  1. Trigger code paths that create manual spans.
  2. In SigNoz Traces, filter by service.name or span name.
  3. Open a trace and check attributes, events, and error status.
  4. Use has_error = true to find spans with recorded exceptions.

Troubleshooting

Still not seeing data in SigNoz? Work through Debug missing traces, logs, and metrics, which covers SDK diagnostics, Collector connectivity, and the common ingestion errors for all three signals.

Why don't I see my spans in SigNoz?

  • Tracer provider must initialize before your app code runs.
  • Check sampler settings. Ratio-based sampling may drop spans in dev.
  • Confirm traffic actually hits the instrumented functions.

Why are child spans missing?

  • Use startActiveSpan which sets parent context automatically.
  • If using startSpan, you must manage context manually.
  • Parent span can't end before children are created.

Why don't attributes appear?

  • Values must be strings, booleans, numbers, or arrays of these.
  • Set attributes before span.end(). Post-end mutations are ignored.

Why does the downstream service start its own trace?

  • Make sure that the caller runs propagation.inject and that the outgoing request carries a traceparent header.
  • Make sure that the receiver passes the extracted context as the third argument to startActiveSpan(name, options, ctx, fn).
  • Make sure that both services register the same propagator format. See Context Propagation.

Spans disconnected across async calls?

  • startActiveSpan handles async context in most cases.
  • For complex flows, use context.with() to propagate context explicitly.

Next steps

If you need help with the steps in this topic, please reach out to us on SigNoz Community Slack. If you are a SigNoz Cloud user, please use in product chat support located at the bottom right corner of your SigNoz instance or contact us at cloud-support@signoz.io.

Is this page helpful

Last updated—September 03, 2026

Edit on GitHub