Custom tracing
Wrap your own business logic in spans with record, the span and spanAll decorators, and the attribute, event and propagation helpers.
Auto-instrumentation covers requests, queries and outgoing calls. Your own logic, such as pricing a cart or generating an invoice, shows up only as the gap between them. The helpers and decorators on this page add named spans for that work. Use them instead of the @adonisjs/otel versions: ours attach source lines and the full Error.cause chain to every exception they record.
| Import | Exports |
|---|---|
@unhandled/agent-adonisjs/helpers | record, handleError, getCurrentSpan, setAttributes, recordEvent, injectTraceContext, extractTraceContext, setUser |
@unhandled/agent-adonisjs/decorators | span, spanAll |
record is also exported from @unhandled/agent-adonisjs.
record
record(name, callback) starts a span, makes it the active span, and runs callback with it. It returns whatever the callback returns, for sync and async callbacks alike. The span ends when the callback returns or its promise settles.
import { record } from '@unhandled/agent-adonisjs/helpers'
export default class InvoiceService {
async issue(order: Order) {
return record('invoice.issue', async (span) => {
span.setAttribute('order.id', order.id)
span.setAttribute('invoice.total_cents', order.totalCents)
const invoice = await Invoice.createFromOrder(order)
await invoice.renderPdf()
return invoice
})
}
}Queries and HTTP calls made inside the callback become children of invoice.issue, so the trace shows the whole step as one block.
If the callback throws or its promise rejects, record adds an exception event to the span, sets its status to ERROR, ends it and rethrows the original error. You do not need a try/catch to get the error on the span.
Decorators
The decorators wrap methods in record. They use TypeScript's experimentalDecorators, which AdonisJS apps have on by default.
span
@span() traces one method. Without options the span is named <ClassName>.<methodName>.
import { span } from '@unhandled/agent-adonisjs/decorators'
export default class CheckoutService {
@span()
async priceCart(cart: Cart) {
// span name: CheckoutService.priceCart
}
@span({ name: 'checkout.charge', attributes: { 'payment.provider': 'stripe' } })
async charge(order: Order) {
// span name: checkout.charge
}
}| Option | Effect |
|---|---|
name | Span name. Defaults to <ClassName>.<methodName>. |
attributes | Attributes set on every span this method creates. |
spanAll
@spanAll() traces every method defined on the class prototype. Spans are named <prefix>.<methodName>, where prefix defaults to the class name.
import { spanAll } from '@unhandled/agent-adonisjs/decorators'
@spanAll({ prefix: 'orders' })
export default class OrderService {
async create(cart: Cart) {
// span name: orders.create
}
async cancel(orderId: number) {
// span name: orders.cancel
}
}It wraps regular methods declared in the class body. The constructor, getters and setters, static methods, #private methods and inherited methods are left alone. spanAll also accepts attributes, applied to every wrapped method.
Attributes and events
These helpers act on the active span. When no span is active they do nothing.
import { getCurrentSpan, recordEvent, setAttributes } from '@unhandled/agent-adonisjs/helpers'
setAttributes({ 'order.id': order.id, 'order.item_count': order.items.length })
recordEvent('order.paid', { 'payment.method': 'card' })
const active = getCurrentSpan()
active?.updateName('orders.checkout')getCurrentSpan() returns the active span, or undefined when there is none.
handleError
handleError(span, error) is what record uses internally: it records the exception with source lines and the cause chain, sets ERROR status, ends the span and rethrows. Use it when you start and end a span yourself with the OpenTelemetry API.
import { trace } from '@opentelemetry/api'
import { handleError } from '@unhandled/agent-adonisjs/helpers'
const span = trace.getTracer('shop').startSpan('inventory.reserve')
try {
await inventory.reserve(order)
span.end()
} catch (error) {
handleError(span, error)
}Propagating context
To continue a trace in another service, write the trace context into the outgoing headers. Outgoing node:http calls are propagated automatically; use this for transports the agent does not instrument.
import { injectTraceContext } from '@unhandled/agent-adonisjs/helpers'
const headers: Record<string, string> = { 'content-type': 'application/json' }
injectTraceContext(headers)
await warehouseQueue.publish({ headers, body: { orderId: order.id } })On the receiving side, read the context back and run your work inside it:
import { context } from '@opentelemetry/api'
import { extractTraceContext, record } from '@unhandled/agent-adonisjs/helpers'
const parent = extractTraceContext(message.headers)
await context.with(parent, () =>
record('warehouse.pick', () => pickItems(message.body.orderId))
)Recorded exceptions
When record, handleError, @span or @spanAll catch an error, the span gets an exception event with:
| Attribute | Content |
|---|---|
exception.type, exception.message, exception.stacktrace | The thrown error. |
unhandled.exception.frames | Parsed stack frames. Frames from your own source files under the app root include up to 7 source lines before and after the failing line, each cut at 300 characters. |
unhandled.exception.cause_chain | Every cause, and the children of an AggregateError, with type, message and where each came from. |
Source lines come from your files on disk, so the issue view shows your code without source maps. The agent reads each file once, off the event loop, into a shared cache. The first error from a file not yet read reaches the span without source lines, while its exception log record still gets them. Files over 1 MiB, such as bundles, get no source lines. Lines in an error name or message that look like stack frames are ignored, and dotfiles such as .env are never read. Which files are read has the full rules. The chain follows cause up to 5 levels deep and up to 10 children per AggregateError.
Imports left on @adonisjs/otel/helpers or @adonisjs/otel/decorators keep working only if you install @adonisjs/otel yourself. They record plain OpenTelemetry exceptions without frames or the cause chain.
Spans outside a request
Work that runs outside a request, job or Ace command, such as a timer or a polling loop, has no parent span. Each query it makes becomes its own trace. Wrap the loop body in record so the queries share one parent:
setInterval(() => {
record('invoices.retry_failed', () => InvoiceRetrier.run())
}, 60_000)See Background spans for more cases.