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
- Complete the Node.js instrumentation guide so your tracer provider and exporters are configured.
- Install the API package:
npm install @opentelemetry/api - Tested with Node.js 18 and
@opentelemetry/apiv1.9.0.
Step 1. Create manual spans
Get a tracer and wrap operations in spans:
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:
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 afinallyblock.
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:
// 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():
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.
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:
// 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.
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-conventionsconst { 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:
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()
}
})
}recordExceptionattaches the stack trace.SpanStatusCode.ERRORflags the span in SigNoz error views and alerts.- Re-throw so calling code can handle the failure.
Step 5. Add span links (optional)
Links connect causally related spans that aren't parent-child:
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
- Trigger code paths that create manual spans.
- In SigNoz Traces, filter by
service.nameor span name. - Open a trace and check attributes, events, and error status.
- Use
has_error = trueto 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
startActiveSpanwhich 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.injectand that the outgoing request carries atraceparentheader. - 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?
startActiveSpanhandles async context in most cases.- For complex flows, use
context.with()to propagate context explicitly.
Next steps
- Correlate traces with logs for faster debugging
- Return to the Node.js instrumentation guide
- Exclude HTTP endpoints such as health checks, etc.
- Read Context Propagation for the wire format and the failure modes shared across languages
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.