Semantic conventions
The span kinds, attribute names and unhandled.* keys that decide how Unhandled classifies, groups and displays your telemetry.
unhandled stores every attribute you send, but its views read specific names. A span becomes a request, a query or a job because of its kind and a few attributes, following the OpenTelemetry semantic conventions plus a small set of unhandled.* keys. Use this page when you instrument code by hand, write your own instrumentation, or wonder why a span lands in the wrong place. The rules live in @unhandled/protocol, shared by the agents and the ingest server.
Span categories
Each span gets one category. The rules run in this order and the first match wins:
| Order | Category | Rule |
|---|---|---|
| 1 | http | Kind SERVER and http.request.method is set |
| 2 | query | Kind CLIENT, db.system.name is set and is not redis |
| 3 | external | Kind CLIENT and http.request.method is set |
| 4 | cache | cache.operation is set |
| 5 | mcp | mcp.method.name is set |
| 6 | job | messaging.system is set |
| 7 | cli | entry_point.type or unhandled.entry_point is cli, or kind INTERNAL and process.executable.name is set |
| 8 | ai | gen_ai.operation.name, gen_ai.provider.name (or the older gen_ai.system) or OpenLLMetry's llm.request.type is set |
| 9 | email | email.system is set |
| none | other | Anything else |
Kind matters. A database span with kind INTERNAL is not a query, and an HTTP span with kind INTERNAL is neither a request nor an external call. An empty string counts as not set.
Within a category, spans are grouped for lists and charts by a group key:
| Category | Group key |
|---|---|
http | http.request.method plus http.route, or url.path with identifiers replaced by :id |
query | db.query.text with literals, numbers and IDs replaced by ?, else the span name |
external | server.address, else the host of url.full |
job | messaging.job.name, then messaging.bullmq.job.name, then messaging.destination.name |
cache | cache.key with ID and email segments replaced by * |
email | email.template, else email.message.subject with numbers replaced by # |
cli | cli.command.name, then process.command, then process.executable.name |
mcp | mcp.tool.name, mcp.prompt.name or mcp.resource.uri, else mcp.method.name |
ai | gen_ai.response.model, then gen_ai.request.model, then gen_ai.tool.name |
Any category falls back to the span name when its attributes are missing. Set http.route to a template such as /orders/:id rather than relying on path guessing.
Service segments
A segment is the part of a trace that ran inside one service. Its first span is the entry point, and it holds the route, job or command context that the segment's queries, AI calls and exceptions are shown with.
A span starts a segment when:
- it has no parent, or
- its kind is SERVER, CONSUMER or INTERNAL and the OTLP span flags say the parent is remote.
Apart from root spans, CLIENT and PRODUCER spans never start a segment. When the span flags do not say whether the parent is remote, SERVER and CONSUMER spans start a segment and others do not. A root CLIENT span from a backend service is flagged as a background span.
Legacy attribute names
Ingest renames older attribute names to the current ones. If a span carries both, the current name wins.
| Old name | Current name |
|---|---|
http.method | http.request.method |
http.status_code | http.response.status_code |
http.target | url.path |
http.url | url.full |
net.peer.name | server.address |
net.peer.port | server.port |
db.system | db.system.name |
db.statement | db.query.text |
db.operation | db.operation.name |
db.sql.table | db.collection.name |
db.name | db.namespace |
messaging.operation | messaging.operation.type |
messaging.destination | messaging.destination.name |
enduser.id | user.id |
enduser.role | user.roles |
GenAI names from older semconv releases, OpenLLMetry, Mastra and the Vercel AI SDK are renamed the same way, before token counts and cost are read:
| Old name | Current name |
|---|---|
gen_ai.system | gen_ai.provider.name |
gen_ai.usage.prompt_tokens | gen_ai.usage.input_tokens |
gen_ai.usage.completion_tokens | gen_ai.usage.output_tokens |
gen_ai.usage.input_tokens.cached, gen_ai.usage.cache_read_input_tokens, gen_ai.usage.cached_input_tokens | gen_ai.usage.cache_read.input_tokens |
gen_ai.usage.input_tokens.cache_write, gen_ai.usage.cache_creation_input_tokens, gen_ai.usage.cache_creation.input_tokens | gen_ai.usage.cache_write.input_tokens |
gen_ai.usage.output_tokens.reasoning, gen_ai.usage.reasoning_tokens, llm.usage.reasoning_tokens | gen_ai.usage.reasoning.output_tokens |
gen_ai.tool.input | gen_ai.tool.call.arguments |
gen_ai.tool.output | gen_ai.tool.call.result |
ai.usage.cost | gen_ai.usage.cost |
Resource attributes
| Attribute | Use |
|---|---|
service.name | Overwritten with the name of the service your DSN belongs to |
service.version | The release. A new version on entry spans records a deployment |
vcs.ref.head.revision | Full 40-character commit SHA, linked to the deployment |
vcs.repository.url.full | Repository URL, stored with the resource |
deployment.id | Your platform's deployment ID, stored with the resource |
service.instance.id | Identifies the process for system metrics; host.name is the fallback |
host.name | Host name |
deployment.environment.name | Overwritten with the x-unhandled-env header |
telemetry.sdk.language | webjs marks a browser service |
service.version values 0.0.0, 0.0.1, 1.0.0, unknown, dev and latest are treated as missing. Unhandled's agents also set unhandled.service.version.source (where the version came from), unhandled.language.name, unhandled.framework.name, unhandled.sdk.name and unhandled.sdk.version.
HTTP requests
SERVER spans.
| Attribute | Use |
|---|---|
http.request.method | Required for the http category |
http.route | Route template used for grouping |
url.path | Grouping fallback when there is no route |
http.response.status_code | 4xx and 5xx counts and error rates |
adonis.route.name | Named AdonisJS route. Stored, not read by any view |
unhandled.http.request.body, unhandled.http.response.body | Captured bodies, shown on the span |
Database queries
CLIENT spans.
| Attribute | Use |
|---|---|
db.system.name | Required. redis is excluded from queries |
db.query.text | Statement, normalized for grouping. Send parameterized SQL |
db.operation.name | Operation such as SELECT |
db.collection.name | Table |
db.namespace | Database name |
db.transaction | true for statements inside a transaction |
External calls
CLIENT spans.
| Attribute | Use |
|---|---|
http.request.method | Required for the external category |
server.address | Host, used for grouping |
url.full | Full URL; the host is the grouping fallback, the path the pattern fallback |
url.template | URL pattern per host, such as /orders/{id} |
http.response.status_code | A status of 400 or more counts as an error |
Exceptions
Three shapes become exceptions:
- Span events named
exception, the OpenTelemetry default fromspan.recordException(). Unhandled readsexception.type,exception.messageandexception.stacktrace, treatsexception.escaped=trueas unhandled, and copiesuser.id,user.emailanduser.namefrom the span. - Log records with
unhandled.log.source=exception_reporter, which is how Unhandled's SDKs report errors. Besides the threeexception.*attributes they carry:
| Attribute | Use |
|---|---|
unhandled.exception.values | JSON array of the error and its causes, with mechanism and frames |
unhandled.exception.values_version | Format version of the field above, currently 1 |
unhandled.exception.frames | JSON stack frames of the root error |
unhandled.exception.cause_chain | JSON list of causes |
unhandled.exception.occurrence_id | Unique ID that deduplicates retries |
unhandled.exception.should_report | false keeps the occurrence out of issues |
unhandled.message.level | Level for captured messages, such as warning |
unhandled.tag.* | Tags |
unhandled.extra.* | Extra context |
- Any other log record with
exception.typeorexception.message, as standard logging bridges send for a logged error. It becomes an occurrence with mechanismlog_recordandhandled: true, with its level taken from the record's severity. Records that carry anyunhandled.log.sourcevalue skip this path.
exception.stacktrace is parsed into frames for Python tracebacks, Go stack dumps, Ruby backtraces, Java stack traces and V8 stacks. Library frames are marked as not in-app: node_modules and node: for JavaScript, site-packages, dist-packages and the standard library for Python, pkg/mod, vendor and the Go runtime for Go, gems and the standard library for Ruby, and java.*, javax.*, jdk.*, sun.*, kotlin.* and scala.* for Java.
Exceptions are grouped into issues by a fingerprint of the type and the top 3 in-app stack frames, or the type and the normalized message when no frame is in-app.
Logs
Every log record is stored with its body, trace and span IDs and attributes. The level comes from severityNumber (1 to 4 trace, 5 to 8 debug, 9 to 12 info, 13 to 16 warn, 17 to 20 error, 21 and above fatal). Without a number, severityText is read case-insensitively: information maps to info, warning to warn, critical to fatal. Anything else is info. Records under the scope unhandled.analytics are analytics events, not logs. unhandled.log.source=console marks logs captured from the browser console.
Users
| Attribute | Use |
|---|---|
user.id | Required to count users |
user.email | Shown with the user |
user.name | Shown with the user |
Set them on the SERVER span of the request. enduser.id is renamed to user.id at ingest.
Jobs
| Attribute | Use |
|---|---|
messaging.system | Required. Unhandled's instrumentations send bullmq or boringqueue |
messaging.operation.type | send or create for a dispatch, process for an execution |
messaging.job.name | Job name, used for grouping |
messaging.destination.name | Queue |
messaging.message.id | Job ID |
messaging.message.retry.count | Attempts so far |
messaging.job.queue_time_ms | Time spent waiting in the queue |
messaging.batch.message_count | Jobs in a bulk dispatch |
A PRODUCER span, or any span with messaging.operation.type of send, publish or create, counts as a dispatch. Link or parent the execution span to the dispatch span so the two show up together.
Cache
| Attribute | Use |
|---|---|
cache.operation | Required, for example get, set or getOrSet |
cache.key | Key, sanitized for grouping |
cache.hit | true or false, used for the hit rate |
cache.store | Store name |
Any span with email.system counts as an email. No OpenTelemetry convention exists for email, so any SDK can set these attributes. The AdonisJS agent's mail instrumentation sets them on CLIENT spans.
| Attribute | Use |
|---|---|
email.system | Required. The agent sends adonisjs-mail |
email.operation | send |
email.transport | Mail transport |
email.message.subject | Subject, used for grouping |
email.message.from, email.message.to | Sender and recipients (JSON array) |
email.message.cc_count, email.message.bcc_count | Copy counts |
email.message.has_attachments | Attachments flag |
email.message.id | Message ID |
email.recipient_count | Recipient count |
Commands
A span is a command when one of these holds:
entry_point.typeiscli, which the AdonisJS agent sets.unhandled.entry_pointiscli. Any SDK can set it on the root span of a command.- The span kind is INTERNAL and the span carries
process.executable.name, as the OpenTelemetry CLI span convention describes. The attribute must be on the span. A resource attribute does not count.
| Attribute | Use |
|---|---|
entry_point.type, unhandled.entry_point | cli marks a command |
cli.command.name | Command name, grouping |
process.command, process.executable.name | Name fallbacks, in this order |
cli.command.description | Description |
cli.args.* | Arguments |
cli.flags.* | Flags |
AI
| Attribute | Use |
|---|---|
gen_ai.operation.name | For example chat, execute_tool or invoke_agent; decides how usage is counted (below) |
gen_ai.request.model, gen_ai.response.model | Model, used for grouping and cost |
gen_ai.provider.name | Provider |
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | Token counts. Input includes cached tokens, as semconv defines it |
gen_ai.usage.cache_read.input_tokens | Input tokens read from the provider's cache, priced at the cache-read rate |
gen_ai.usage.cache_write.input_tokens | Input tokens written to the cache, priced at the cache-write rate |
gen_ai.usage.reasoning.output_tokens | Reasoning tokens, part of the output tokens |
gen_ai.usage.cost | Cost in USD reported by the provider or gateway; used instead of the estimate |
gen_ai.tool.name | Tool name |
gen_ai.conversation.id | Groups calls into a conversation |
gen_ai.function_id, gen_ai.agent.name | Agent or function name |
gen_ai.system_instructions, gen_ai.input.messages | Prompt content |
gen_ai.output.messages | Response content; its text parts are shown as the response of a conversation step |
gen_ai.tool.call.arguments, gen_ai.tool.call.result | Tool payloads |
One count per model call. Agent and workflow spans (invoke_agent, create_agent, invoke_workflow) usually repeat the summed usage of the model calls inside them. Calls, tokens and cost count every other span with an operation (chat, generate_content, text_completion, embeddings and so on), except execute_tool and the retrieval and memory operations. An agent span counts only when no model call that reported usage started inside its time window. This covers frameworks that emit only the agent total, and remote agents called next to ordinary model calls. Only the outermost such agent span counts.
Cost. Unhandled prices usage when you look at it, from a dated table of list prices, so adding a model prices its earlier calls too and a price change only applies from its date. Bedrock (anthropic.claude-…), Vertex, OpenRouter (anthropic/claude-…) and dated model ids match the model they name. A call that sends gen_ai.usage.cost keeps that cost. A model without a price shows as unpriced rather than $0.
Content. Prompt, response and tool payload attributes are kept up to 256 KiB each, the same limit for every content key, including OpenLLMetry's gen_ai.prompt.* and gen_ai.completion.*. Before they are stored, they go through the same secret rules as exception data, plus an email rule, and each match becomes [Filtered]. The rules cover private keys, JWTs, bearer tokens, credentials in URLs, provider API keys, password= and "password": "..." style secrets, and card numbers that pass the Luhn check. Operators can turn rules off with INGEST_AI_SCRUB_DISABLE (comma-separated rule names: private_key, jwt, bearer, url_credentials, api_key, secret_assignment, secret_field, card, email) and add patterns with INGEST_AI_SCRUB_PATTERNS (a JSON array of regular expressions). Deleting a project erases its AI content right away, and node ace ai:erase-content --project <id> [--from <iso>] [--to <iso>] erases a range on demand.
Evaluations are span events named gen_ai.evaluation.result with gen_ai.evaluation.name, gen_ai.evaluation.score.value, gen_ai.evaluation.score.label and gen_ai.evaluation.explanation.
MCP
| Attribute | Use |
|---|---|
mcp.method.name | Required, for example tools/call |
mcp.tool.name | Tool |
mcp.client.name, mcp.client.version | Calling client |
mcp.tool.result.is_error | true counts as a failure |
rpc.jsonrpc.error_code, rpc.jsonrpc.error_message | JSON-RPC error, counts as a failure |
Sampling
If you head-sample spans, set unhandled.sampling.ratio on each kept span to the ratio that kept it, from 0.000001 to 1. Unhandled counts such a span 1 / ratio times in call counts, error counts and average durations, so a service sampled at 0.25 shows its full traffic. A missing or out-of-range value counts the span once. @unhandled/node sets the attribute when samplingRatio is below 1.
System metrics
The system metrics view reads process.cpu.utilization, system.cpu.utilization, process.memory.usage, v8js.memory.heap.used, v8js.memory.heap.limit, nodejs.eventloop.utilization and nodejs.eventloop.delay.p99, per service.instance.id. Every other metric is stored and available in metrics.
Reserved Unhandled attributes
The server sets unhandled.organization.id, unhandled.project.id, unhandled.environment and unhandled.service.id from the authenticated request. Values you send under these keys are overwritten.
Attributes starting with unhandled. are hidden in the UI, except unhandled.http.*, unhandled.tag.* and unhandled.extra.*.