docs

Users and exceptions

Attach the signed-in user to traces, understand automatic exception capture, and report errors and messages yourself with the Unhandled object.

This page covers the Unhandled object exported by @unhandled/agent-adonisjs: setUser to tie traces to a customer, and captureException and captureMessage to report problems your code handles itself. It also explains what the agent reports on its own, so you know when you need to call anything at all.

ts
import { Unhandled } from '@unhandled/agent-adonisjs'

setUser is also exported on its own from @unhandled/agent-adonisjs and @unhandled/agent-adonisjs/helpers.

Identify users

Unhandled.setUser({ id, email, name }) writes user.id, user.email and user.name on the active span and on the request's SERVER span. Only id is required; it is stored as a string. The agent middleware keeps a handle on the SERVER span, so the call works from a nested span in a service or a record block too.

The natural place for it is the middleware that authenticates the request, for example the silent auth middleware:

ts
// app/middleware/silent_auth_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { Unhandled } from '@unhandled/agent-adonisjs'

export default class SilentAuthMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    await ctx.auth.check()
    const user = ctx.auth.user
    if (user) {
      Unhandled.setUser({ id: user.id, email: user.email, name: user.fullName ?? undefined })
    }
    return next()
  }
}

User attributes are exported with every trace and visible to everyone who can see the service. Never put tokens, passwords or other secrets in them. The data powers Users.

Automatic user context

The agent middleware reads ctx.auth.user when the request finishes, after route-level auth middleware has run, and records its id and role. It leaves the email out because an email address is personal data. Opt in when you want it on spans and exceptions:

config/unhandled.ts
import { defineConfig } from '@unhandled/agent-adonisjs'

export default defineConfig({
  userContext: { email: true },
})

Set userContext: false to turn the automatic lookup off. setUser and a custom resolver(ctx) send exactly the fields you pass, email included. If resolver throws or rejects, the request keeps its response and its span gets no user. The agent logs the failure once through the OpenTelemetry diag logger.

Exceptions thrown during a request inherit the user from the request span, so they carry the same fields.

Automatic exception capture

The provider hooks report() on the Adonis exception handler. Every error that reaches it is sent as a log record with unhandled.log.source=exception_reporter and grouped into Issues. Failed Ace commands are reported the same way.

The hook decides what to send in this order:

  1. Errors matching the agent's ignoreExceptions, ignoreStatuses or ignoreCodes are not sent at all.
  2. Errors your own exception handler would not report (its shouldReport returns false, for example because of its ignoreStatuses) are sent with unhandled.exception.should_report=false. They are stored with their trace but never open an issue on their own.
  3. Everything else is sent, and the active span gets an exception event, ERROR status and error.type.
ts
import env from '#start/env'
import { defineConfig } from '@unhandled/agent-adonisjs'
import CouponExpiredException from '#exceptions/coupon_expired_exception'

export default defineConfig({
  dsn: env.get('UNHANDLED_DSN'),
  environment: env.get('APP_ENV'),
  ignoreExceptions: [CouponExpiredException],
  ignoreStatuses: [404],
  ignoreCodes: ['E_OUT_OF_STOCK'],
})

ignoreExceptions matches with instanceof, ignoreStatuses compares the error's status and ignoreCodes its code. All three default to [].

Each error object is reported once. If you call captureException and then rethrow, the exception handler will not send it a second time.

Report an exception

Unhandled.captureException(error, context?) reports an error you caught and handled, so it still shows up in Issues.

ts
try {
  await PaymentGateway.charge(order)
} catch (error) {
  Unhandled.captureException(error, {
    user: { id: order.customerId },
    tags: { area: 'checkout', provider: 'stripe' },
    extra: { orderId: order.id, totalCents: order.totalCents },
  })
  await order.markPaymentFailed()
}
FieldTypeStored as
user{ id, email?, name? }user.id, user.email, user.name
tagsRecord<string, string>unhandled.tag.<key>, for filtering
extraRecord<string, unknown>unhandled.extra.<key>, each value JSON encoded

The record is sent at ERROR severity with unhandled.message.level=error. If a span is active, it also gets the exception event and ERROR status. With no active span the record is still sent.

Report a message

Unhandled.captureMessage(message, levelOrContext?) reports something that is not an exception but deserves an issue, such as a fallback path that should never run.

ts
Unhandled.captureMessage('Invoice PDF fell back to plain text')
Unhandled.captureMessage('Stock below safety level', 'warning')
Unhandled.captureMessage('Refund exceeded order total', {
  level: 'error',
  tags: { area: 'refunds' },
  extra: { orderId: order.id },
})

The second argument is either a level or an object with level, user, tags and extra.

LevelSeverity
debugDEBUG
info (default)INFO
logINFO
warningWARN
errorERROR
fatalFATAL

The level is stored as unhandled.message.level; an unknown level falls back to info. Messages carry the stack of the call site and the type Message, so they group into issues the same way exceptions do. captureMessage and captureException never throw. If building the record fails, the agent logs one warning for that call site through the OpenTelemetry diag logger and skips the record.

What an exception record contains

AttributeContent
exception.type, exception.messageThe root error.
exception.stacktraceThe root stack followed by each cause's stack.
unhandled.exception.framesParsed frames, with up to 7 source lines around each frame from your own files.
unhandled.exception.cause_chainEvery cause and AggregateError child.
unhandled.exception.valuesThe full error tree, up to 20 errors and 5 levels deep.
unhandled.exception.occurrence_idUnique id for this occurrence.
unhandled.exception.should_reportfalse when the exception handler would not report it.

Custom events

Unhandled.track(name, properties) records a product event, such as order_placed, for Analytics. It does not create an issue.