Migrate from @adonisjs/otel
Point @adonisjs/otel at Unhandled with standard OTLP variables, or move to the Unhandled agent and keep your existing options, helpers and decorators.
An app on @adonisjs/otel can send to Unhandled without changing packages: keep @adonisjs/otel and add your DSN. Moving to the Unhandled agent gets you more from the same app, as listed in what the agent adds.
Keep @adonisjs/otel
Run one package, not both. The agent's configure hook stops when otel.ts starts @adonisjs/otel, and the agent's provider throws at boot when the @adonisjs/otel provider is registered or its SDK is running. To stay on @adonisjs/otel, use one of the two setups below and leave the agent's provider and middleware out.
With your DSN
Install the agent package for its preset only, without running its configure hook:
pnpm add @unhandled/agent-adonisjsunhandledDestinations() turns the DSN into an @adonisjs/otel destination with the Unhandled endpoint and headers:
import { defineConfig } from '@adonisjs/otel'
import { unhandledDestinations } from '@unhandled/agent-adonisjs/otel_preset'
import env from '#start/env'
export default defineConfig({
serviceName: env.get('APP_NAME'),
serviceVersion: env.get('APP_VERSION'),
environment: env.get('APP_ENV'),
destinations: unhandledDestinations({ dsn: env.get('UNHANDLED_DSN'), environment: env.get('APP_ENV') }),
})Add UNHANDLED_DSN: Env.schema.string.optional() to start/env.ts and the DSN to .env. Without a DSN the function returns no destination, and @adonisjs/otel falls back to the OTEL_EXPORTER_OTLP_* variables. A DSN that does not parse throws at boot. environment must be production, staging or development.
The preset sets up export only. Exception capture with source lines, mail and Ace spans, job sampling and version detection come with the agent itself: see what the agent adds.
ClickHouse on @adonisjs/otel
@adonisjs/otel has no instrumentation for @clickhouse/client, so its calls show up as plain HTTP requests to port 8123. The preset exports the agent's ClickHouseInstrumentation. Add it to instrumentations in the same config/otel.ts:
import { defineConfig } from '@adonisjs/otel'
import {
CLICKHOUSE_INSTRUMENTATION_NAME,
ClickHouseInstrumentation,
unhandledDestinations,
} from '@unhandled/agent-adonisjs/otel_preset'
import env from '#start/env'
export default defineConfig({
serviceName: env.get('APP_NAME'),
environment: env.get('APP_ENV'),
destinations: unhandledDestinations({ dsn: env.get('UNHANDLED_DSN'), environment: env.get('APP_ENV') }),
instrumentations: { [CLICKHOUSE_INSTRUMENTATION_NAME]: new ClickHouseInstrumentation() },
})@adonisjs/otel loads this file before your app's modules, so a client created at module level, such as export const client = createClient({ url, database, username, password }) in app/utils/clickhouse.ts, is traced from its first call. Each query, insert, command and exec becomes one SELECT events-style span under the request or job span, with the query text sanitized and no HTTP span next to it. clickhouse_settings and abort_signal work as before. ClickHouse lists the attributes.
The app keeps one config file. config/otel.ts configures @adonisjs/otel and stays; there is no config/unhandled.ts, because that file belongs to the agent, which refuses to boot beside @adonisjs/otel. When you move to the agent, config/unhandled.ts replaces config/otel.ts, and ClickHouse spans need no entry at all.
With OTEL_* variables
When config/otel.ts has no destinations, @adonisjs/otel leaves export to the OpenTelemetry Node SDK, which reads the standard OTEL_EXPORTER_OTLP_* variables and exports traces, metrics and logs over OTLP. Point them at Unhandled with values taken from your DSN:
OTEL_SERVICE_NAME=orders-api
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=uh_live_…,x-unhandled-service-id=0b6f3c1e-…,x-unhandled-env=production
APP_ENV=production| Variable | Value |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | The DSN's origin. The SDK appends /v1/traces, /v1/metrics and /v1/logs. |
OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf, the SDK default. http/json works too. Unhandled does not accept gRPC. |
OTEL_EXPORTER_OTLP_HEADERS | The API key and service id from the DSN, plus the environment the data belongs to. |
APP_ENV | The generated config/otel.ts passes it as environment. Keep it equal to x-unhandled-env. |
OTEL_SERVICE_NAME | Used only when serviceName is unset. The generated config passes APP_NAME, which wins. Unhandled files the data under the DSN's service either way. |
.env is early enough: config/otel.ts imports #start/env, which loads the file before the SDK starts. The setup dialog fills in all five lines when you pick AdonisJS and then Already using @adonisjs/otel.
What the agent adds
@unhandled/agent-adonisjs starts the same OpenTelemetry SDK and adds:
- One
UNHANDLED_DSNinstead of the endpoint and header variables, and an environment checked againstproduction,staginganddevelopment. - Database query spans.
@adonisjs/otelturns thepg,mysqlandmysql2instrumentations off by default. - ClickHouse spans for
@clickhouse/client, on by default. On@adonisjs/otelyou add them yourself, as above. - Spans for Ace commands.
@adonisjs/otelimportsotel.jsinbin/server.tsonly, while the agent also imports it inbin/console.ts. - Spans for
@adonisjs/mailand@adonisjs/queue, and job sampling. - Exceptions with source lines and the
Error.causechain, where@adonisjs/otelrecords the plain exception. - The code version from your platform, CI or git when
APP_VERSIONis unset or a placeholder such as the0.0.1thatnode ace add @adonisjs/otelwrites. - A dev mode that ignores the DSN, so a laptop never writes into a production service.
Both packages set user.id from ctx.auth.user in their middleware.
Move to the Unhandled agent
The Unhandled agent replaces @adonisjs/otel. It is a thin layer on @unhandled/node and does not depend on @adonisjs/otel. It starts an OpenTelemetry SDK, sets the route in its own middleware and accepts most of your options, then adds DSN-based export, richer exception capture, mail, queue and Ace command spans, and job sampling. If your app already uses @adonisjs/otel, moving over takes a few minutes and most of your config carries across as is.
What changes
| Area | @adonisjs/otel | Unhandled agent |
|---|---|---|
| Config file | config/otel.ts | config/unhandled.ts |
| Startup | @adonisjs/otel/init | @unhandled/agent-adonisjs/init |
| Provider | @adonisjs/otel/otel_provider | @unhandled/agent-adonisjs/unhandled_provider |
| Middleware | @adonisjs/otel/otel_middleware | @unhandled/agent-adonisjs/unhandled_middleware, which sets the route itself |
| Helpers | @adonisjs/otel/helpers | @unhandled/agent-adonisjs/helpers |
| Decorators | @adonisjs/otel/decorators | @unhandled/agent-adonisjs/decorators |
1. Point otel.ts at the agent, then install
Open otel.ts at the project root and make it start the Unhandled agent. The configure hook refuses to run while otel.ts still starts @adonisjs/otel.
import { init } from '@unhandled/agent-adonisjs/init'
await init(import.meta.dirname)Then remove the old provider and middleware (step 2), and install the agent:
node ace add @unhandled/agent-adonisjsThe command creates config/unhandled.ts, adds the Unhandled provider and middleware, adds APP_ENV and UNHANDLED_DSN to .env and start/env.ts, and inserts import '../otel.js' as the first import of bin/console.ts. bin/server.ts already has that import from your @adonisjs/otel setup and is left alone. Existing .env keys are never overwritten.
2. Remove the old provider and middleware
Delete @adonisjs/otel/otel_provider from the providers in adonisrc.ts, and @adonisjs/otel/otel_middleware from the router middleware in start/kernel.ts. The Unhandled provider and middleware replace them. While the old provider is registered, the agent's provider throws at boot.
3. Move your options
Copy the options from config/otel.ts into the defineConfig call in config/unhandled.ts, then delete config/otel.ts. The agent reads only config/unhandled.ts.
import env from '#start/env'
import { defineConfig } from '@unhandled/agent-adonisjs'
export default defineConfig({
dev: env.get('NODE_ENV') === 'development',
dsn: env.get('UNHANDLED_DSN'),
environment: env.get('APP_ENV'),
samplingRatio: 0.5,
userContext: false,
resourceAttributes: { 'shop.region': 'eu-west' },
})serviceName, serviceVersion, samplingRatio, debug, enabled, resourceAttributes, instrumentations, userContext and the other SDK options keep their names and meaning. Three behave differently:
| Option | Difference |
|---|---|
environment | Must be production, staging or development; anything else throws UnhandledConfigError. When unset it falls back to UNHANDLED_ENV, then APP_ENV when that is one of the three values, then NODE_ENV, then development. The generated config passes environment: env.get('APP_ENV'). |
destinations | An array of destinations.otlp({ name, url, headers }) instead of an object keyed by name. Each extra destination receives traces, logs and metrics. See Extra destinations. |
enabled | The SDK starts only when there is somewhere to send data (a DSN, a dev endpoint or an extra destination) or debug is on. |
Without serviceVersion, the agent detects the version from UNHANDLED_VERSION, APP_VERSION, your platform, your CI or git instead of falling back to a fixed string. APP_NAME and APP_VERSION are no longer required; leave them if other code reads them. See Service name and version.
For HTTP noise and redaction, prefer the top-level http key over instrumentations['@opentelemetry/instrumentation-http']. A config object under the raw key merges with what the agent builds, and an HttpInstrumentation instance replaces it. See HTTP tracing.
The debug channel is now NODE_DEBUG=unhandled. It was adonisjs:otel. The agent also no longer patches the route onto pino log records.
4. Update helper and decorator imports
import { record, setAttributes } from '@adonisjs/otel/helpers'
import { span, spanAll } from '@adonisjs/otel/decorators'
import { record, setAttributes } from '@unhandled/agent-adonisjs/helpers'
import { span, spanAll } from '@unhandled/agent-adonisjs/decorators'getCurrentSpan, setAttributes, recordEvent, injectTraceContext and extractTraceContext are the same functions, re-exported. record, handleError, @span and @spanAll are replaced: when they catch an error, they attach source lines from your files and the full Error.cause chain, including AggregateError children. setUser is replaced too and takes { id, email, name }.
Imports you miss keep working only while @adonisjs/otel stays in your own package.json, because the agent no longer installs it. Old and new imports can live side by side; the old ones just record plain OpenTelemetry exceptions without frames or the cause chain. See Custom tracing.
5. Add the DSN and restart
Paste the service DSN into UNHANDLED_DSN for each environment, then restart the web process and every worker. Send a request and check that the trace appears under the expected service and environment.
You can remove @adonisjs/otel from your own package.json once nothing in your code imports it. The agent does not need it.
Coming from an older agent config
If your config/unhandled.ts still has the top-level options ai, mastra, bullmq or cache, the agent throws at boot and names the package to install instead. Install that package and add its factory to integrations:
import env from '#start/env'
import { defineConfig } from '@unhandled/agent-adonisjs'
import { bullmq } from '@unhandled/instrumentation-bullmq/integration'
import { vercelAi } from '@unhandled/instrumentation-vercel-ai/integration'
export default defineConfig({
dsn: env.get('UNHANDLED_DSN'),
environment: env.get('APP_ENV'),
integrations: [bullmq(), vercelAi()],
})Integrations you leave out record nothing. mail, queue and cli are still built in and still configured at the top level. See Configuration.