docs

HTTP tracing

How the AdonisJS agent names request spans, which URLs it skips, how it redacts URLs, and how to capture request and response bodies.

Every request your app serves becomes a SERVER span, and every call it makes with node:http or node:https becomes a CLIENT span. This page covers how those spans are named, how to cut noise, how to keep secrets out of URLs, and how to record bodies when you need to debug a payload. Everything here lives under the top-level http key of defineConfig, plus ignoreOptionsRequests.

Request spans

The agent middleware runs first in the router middleware stack. Once the route is resolved, it renames the SERVER span to the HTTP method and route pattern, such as POST /orders/:id/refund, so requests to different ids group together. It also sets:

AttributeValue
http.routeThe route pattern, such as /orders/:id/refund.
adonis.route.nameThe route name, or unknown for unnamed routes.
http.response.status_codeThe status your app returned.

User attributes and exceptions on request spans are covered in Users and exceptions. The data feeds Requests and External calls.

Ignored requests

Some requests never get a span:

  • OPTIONS requests (CORS preflight), while ignoreOptionsRequests is true, the default.
  • Health and metadata paths that @unhandled/node ignores by default: /health, /healthz, /internal/healthz, /ready, /readiness, /metrics, /internal/metrics, /favicon.ico, /robots.txt, /sitemap.xml, /manifest.json, /site.webmanifest, /browserconfig.xml and /ads.txt.
  • Static files such as stylesheets, scripts, images and fonts, and Vite dev server paths.
  • Anything matching http.ignoredUrls.
ts
import env from '#start/env'
import { defineConfig } from '@unhandled/agent-adonisjs'

export default defineConfig({
  dsn: env.get('UNHANDLED_DSN'),
  environment: env.get('APP_ENV'),
  http: {
    ignoredUrls: ['/internal/*', '/webhooks/heartbeat'],
  },
})

ignoredUrls adds to the defaults. A * matches any characters, a plain path also matches every path below it (/webhooks/heartbeat skips /webhooks/heartbeat/eu), and the query string is ignored when matching.

URL sanitizing

URLs often carry secrets: OAuth codes, signed links, reset tokens. The agent rewrites them on SERVER and CLIENT spans before export, with no setup.

  • A query value is replaced when its name matches the body capture redaction lists, and also for state, code_verifier, sig, key, jwt, reset, sso, ticket, bearer, pass, nonce and hash. These extra names match exactly, so hashtag and passage stay.
  • A path segment is replaced when it is a UUID, a JWT, an email address, or 16 or more letters, digits, _ or - that include a digit. A segment with a hyphen counts only when it mixes upper- and lowercase letters, so slugs such as release-notes-2024 and ABC-DEF-GHIJ-2024 stay. A ;jsessionid= value inside a segment is replaced as well.
  • The path rule is a heuristic and errs both ways. It misses short secrets such as the code in /verify/483920, base64 with = padding and lowercase hyphenated tokens, and it replaces some readable names that contain a digit, such as /files/quarterly_report_2024_final. Add queryParams or a custom sanitize for the routes it gets wrong.

A request to /auth/callback?code=4f2a&state=9c1e&next=/orders is stored as /auth/callback?code=REDACTED&state=REDACTED&next=%2Forders. /invitations/9f86d081884c7d659a2feaa0c55ad015 is stored as /invitations/REDACTED, and /posts/1 stays as it is.

ts
defineConfig({
  http: {
    sanitizeUrls: { queryParams: ['invite'] },
  },
})
OptionDefaultEffect
queryParams[]More query parameter names whose values are replaced. Matching ignores case and separators, so accessToken, access_token and Access-Token are the same name.
censor'REDACTED'Replacement text.
sanitizenone(url: URL) => string. Replaces the whole URL. When set, the built-in rules, queryParams and censor are not used.

sanitizeUrls: false turns the rewrite off, and so does UNHANDLED_SANITIZE_URLS=false when sanitizeUrls is not set in the config. The rewrite applies to the http.url, url.full, url.path, http.target and url.query attributes. A custom sanitize lets you drop the query entirely on sensitive routes:

ts
defineConfig({
  http: {
    sanitizeUrls: {
      sanitize: (url) => (url.pathname.startsWith('/invoices/download') ? url.pathname : url.href),
    },
  },
})

Upgrading from a version where sanitizing was opt-in:

  • Without sanitizeUrls, query secrets and token path segments now arrive as REDACTED. Set sanitizeUrls: false to keep the old output.
  • queryParams now adds to the built-in names instead of replacing them.
  • A custom sanitize now also rewrites url.path.

Body capture

Body capture records request and response payloads on the span, for example to see what a failed checkout actually posted. It is off by default and costs memory and storage, so turn on only the sides you need.

ts
defineConfig({
  http: {
    captureBodies: {
      incoming: {
        request: { enabled: true },
        response: { enabled: true, maxBodySize: 4_096 },
      },
      outgoing: {
        request: { enabled: true },
      },
      redact: { bodyKeys: ['iban', 'giftCardCode'], headers: ['x-shop-signature'] },
    },
  },
})
OptionDefaultEffect
enabledfalseTurns on all four sides at once. A per-side enabled wins over it.
incoming.request, incoming.responseoffRequests to your app and the responses it sends.
outgoing.request, outgoing.responseoffCalls your app makes and the responses it receives.
maxBodySize10000Bytes kept per body. Set it at the top level or per side. Longer bodies are cut, not dropped.
redact.patternsbuilt-in listSubstrings. Any body key, header or query parameter that contains one is redacted. Replaces the built-in patterns.
redact.bodyKeysbuilt-in listBody keys whose values are replaced, at any depth.
redact.headersbuilt-in listHeader names whose values are replaced.
redact.queryParamsbuilt-in listQuery parameters replaced in the captured URL.
redact.censor'REDACTED'Replacement text.
transformnoneLast hook before the body is stored. See Transform.

Only application/json and application/x-www-form-urlencoded bodies are captured. Other content types, and bodies that do not parse, are skipped. Captured bodies are stored as unhandled.http.request.body and unhandled.http.response.body, and the headers of the same side as http.request.header.* or http.response.header.*.

Response bodies your controllers return as objects are recorded as they are, without a second serialization. Raw body text longer than 64 KiB, or than maxBodySize when that is larger, is not parsed or captured. The span gets unhandled.http.request.body_omitted or unhandled.http.response.body_omitted set to too_large instead.

Outgoing capture hooks node:http and node:https only. Calls made with fetch or undici are not captured.

Built-in redaction

Redaction applies to bodies, headers and captured query strings, even with empty redact lists. Matching ignores case and separators, so x-auth-token, X_Auth_Token and xAuthToken are the same key.

A key is redacted when it contains one of these patterns: password, passwd, secret, token, auth, cookie, session, csrf, api-key / apikey, credit, card, ssn. That covers variants such as newPassword, password_confirmation, currentPassword, x-xsrf-token, x-refresh-token, stripeToken and creditCardNumber. It also covers harmless keys that happen to contain a pattern, such as author or cardinality. To change that, pass redact.patterns, which replaces the list:

ts
import { BUILT_IN_REDACTED_PATTERNS, defineConfig } from '@unhandled/agent-adonisjs'

defineConfig({
  http: {
    captureBodies: {
      enabled: true,
      redact: {
        patterns: [...BUILT_IN_REDACTED_PATTERNS.filter((pattern) => pattern !== 'auth'), 'iban'],
      },
    },
  },
})

These exact names stay redacted whatever patterns says, and your bodyKeys, headers and queryParams add to them:

password, passwd, pwd, secret, token, code, access_token, refresh_token, id_token, auth, authorization, proxy-authorization, api_key, apikey, x-api-key, client_secret, private_key, cookie, set-cookie, session, session_id, otp, cvv, cvc, card_number, credit_card, ssn.

Transform

transform(record) runs after redaction, so it only ever sees redacted data. Mutate the record in place, return a replacement, or return null to skip the capture.

ts
defineConfig({
  http: {
    captureBodies: {
      enabled: true,
      transform: (record) => {
        if (record.http.url.includes('/exports/')) {
          return null
        }
        if (record.http.side === 'response' && record.http.url.includes('/catalog')) {
          record.http.body = '[catalog omitted]'
        }
        return record
      },
    },
  },
})

The record has http.direction (incoming or outgoing), side (request or response), method, url, statusCode on responses, contentType, headers and body. The CaptureRecord type is exported from @unhandled/agent-adonisjs.

Raw instrumentation config

If you set instrumentations['@opentelemetry/instrumentation-http'] to a config object, it merges with what the agent builds. http.ignoredUrls, ignoreOptionsRequests and outgoing body capture stay active, and your ignoreIncomingRequestHook, requestHook and responseHook run alongside the built-in ones. If you set an HttpInstrumentation instance, it replaces the built-in one, and those three features no longer apply to it. URL sanitizing and incoming body capture keep working either way. Use the top-level http key unless you need an option it does not expose.