Naming Spans and Using Semantic Conventions
Every trace backend groups spans by name. Latency percentiles, error rates, throughput per operation, the list of slowest endpoints — all of it is computed per span name. A name that is the same for every execution of an operation produces useful groups; a name that includes an order identifier produces one group per order, none with enough data to mean anything. Attribute names have the same property one level down: a concept named consistently across services can be queried across the fleet. This page covers choosing span names, placing the variable parts in attributes, and adopting semantic conventions so spans from different services mean the same thing. It is a task article under span lifecycle and attributes, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
pip install "opentelemetry-api>=1.27.0,<2.0.0" \
"opentelemetry-semantic-conventions>=0.48b0,<1.0.0"
The semantic conventions package provides the attribute names as constants, which removes spelling mistakes from the equation.
Implementation
Step 1 — Name by operation, never by instance. The test is whether the name would be identical for the next execution of the same operation with different inputs. GET /orders/{order_id} passes; GET /orders/8812 fails. charge payment passes; charge payment for ord_8812 fails. Everything that differs between executions — identifiers, amounts, user input — goes into attributes.
from opentelemetry import trace
tracer = trace.get_tracer("checkout")
# the name is the operation; the instance is in attributes
with tracer.start_as_current_span("charge payment") as span:
span.set_attribute("app.order.id", order.id)
span.set_attribute("app.payment.amount_cents", order.total_cents)
Step 2 — Follow the naming convention for each span kind. The semantic conventions define names by kind, and following them means backends recognise the spans. HTTP server and client spans are named by method and route template. Database spans are named by operation and target, such as SELECT orders. Messaging spans are named by operation and destination, such as publish order-events. Automatic instrumentation follows these already; manual spans for the same kinds should match.
Step 3 — Use semantic convention attribute names. The conventions name common attributes — http.request.method, http.route, http.response.status_code, db.system, db.operation.name, messaging.destination.name, server.address — and using those exact names means the fleet's spans can be queried as one dataset. The constants from the conventions package prevent the typos that quietly split one attribute into two.
from opentelemetry.semconv.trace import SpanAttributes
with tracer.start_as_current_span("GET", kind=trace.SpanKind.CLIENT) as span:
span.set_attribute(SpanAttributes.HTTP_METHOD, "GET")
span.set_attribute(SpanAttributes.HTTP_URL, url)
span.set_attribute(SpanAttributes.NET_PEER_NAME, host)
A note on versions: the conventions have evolved, and older instrumentation emits older names — http.method rather than http.request.method, for example. Pinning instrumentation versions across the fleet keeps every service on the same generation of names, so queries do not have to cover both.
Step 4 — Namespace everything the conventions do not cover. Application attributes — an order identifier, a pricing rule, a tenant plan — belong under a prefix for the organisation or domain. The conventions grow over time; an unprefixed order.id may one day collide with a standard attribute of different meaning, and a prefixed app.order.id never will. The same rule applies to log fields, as in JSON log schemas and conventions.
Step 5 — Bound attribute values too. Attributes may be high-cardinality — that is their purpose — but they should not be unbounded in size. A full SQL statement, a request body or a stack trace in an attribute inflates every span that carries it. The SDK's attribute length limit, set through the environment, caps them globally.
export OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048
export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=128
Expected Output: a span whose name groups and whose attributes query.
{
"name": "GET /orders/{order_id}",
"kind": "SERVER",
"attributes": {
"http.request.method": "GET",
"http.route": "/orders/{order_id}",
"url.path": "/orders/8812",
"http.response.status_code": 200,
"app.order.id": "ord_8812",
"app.tenant.plan": "enterprise"
}
}
Choosing names for manual spans
Automatic instrumentation handles library spans. The names of manual spans — the business operations a service performs — are where most naming decisions are actually made, and a few guidelines keep them useful.
Use verb-object phrases. reserve stock, apply discount rules, render invoice. They read naturally in a trace timeline and describe what the code was doing rather than which function it was in. Function names change with refactoring; operation names describe behaviour and stay stable.
Keep the vocabulary consistent across services. If one service calls it charge payment and another process payment for the same step, cross-service queries have to know both. A short shared list of operation names for common steps is worth agreeing.
Match the granularity to the question. A manual span should wrap something whose duration someone would want to know separately. Wrapping every function produces traces nobody can read; wrapping only the request produces traces that say nothing. A handful of spans per request, each around a distinct step, is usually right, and profiling shows where finer detail is needed, as described in linking profiles to traces with span context.
Never compute the name from input. Even innocuous-looking inputs — a report type, a job name — can grow unboundedly. If the variation matters, it goes in an attribute; if it has a small fixed set of values that genuinely identify different operations, a name per value is acceptable, and that set should be written down.
When the route template is not available
The advice to name server spans by route template assumes the framework knows the template, and most do — Flask, Django, FastAPI and Starlette all expose the matched route. Three situations break that assumption, and each has a workaround.
Requests that match no route. A 404 has no template. Naming the span with the actual path reintroduces unbounded names, one per random URL a scanner tries. The convention is to name such spans by method alone — GET — with the path in an attribute, so unmatched requests form one group rather than thousands.
Frameworks or middleware that run before routing. Middleware that starts a span before the router has matched sees only the raw path. The span can be renamed later, once the route is known, using the span's rename method; framework instrumentation does exactly this. Manual middleware should do the same, or should leave server span creation to the instrumentation and add attributes to the current span instead, as in instrumenting Starlette and ASGI middleware.
Hand-built routing. Services that dispatch on path segments themselves — a proxy, a catch-all handler, a legacy router — have no template to report. The fix is to compute a normalised form: replacing numeric segments and identifiers with placeholders, so /orders/8812/items/3 becomes /orders/{id}/items/{id}. It is imperfect and far better than raw paths, and the normalisation rule belongs in one shared function used by both span naming and metric labels, so the two agree.
In every case, the goal is the same property: the name of a span should be a member of a small, stable set, and an unrecognised request should fall into a catch-all group rather than creating a new one.
Configuration options
| Span kind | Name pattern | Key attributes |
|---|---|---|
| HTTP server | {method} {route} |
http.request.method, http.route, http.response.status_code |
| HTTP client | {method} |
url.full, server.address, http.response.status_code |
| Database | {operation} {target} |
db.system, db.operation.name, db.collection.name |
| Messaging | {operation} {destination} |
messaging.system, messaging.destination.name |
| RPC | {service}/{method} |
rpc.system, rpc.service, rpc.method |
| Business operation | verb-object | app.* namespaced attributes |
Verification
Count distinct span names per service; the number should be stable and small.
SELECT service_name, count(DISTINCT span_name) AS names
FROM spans WHERE start_time > now() - interval '1 day'
GROUP BY service_name ORDER BY names DESC;
Expected Output: tens of names per service, not thousands.
service_name names
checkout 48
billing 31
search 9204 <- a name that contains a value
A service with thousands of names has a span name built from input. Listing its most frequent names shows the pattern immediately.
Common mistakes
Identifiers in span names. Error signature: no latency percentiles for an endpoint, thousands of near-duplicate operations. Root cause: the actual path or an identifier in the name. Remediation: route templates in names, values in attributes.
Home-grown attribute names. Error signature: cross-service queries needing several spellings. Root cause: each service naming common concepts its own way. Remediation: semantic convention names, via the constants package.
Unprefixed application attributes. Error signature: a collision with a standard attribute after a convention update. Root cause: order.id rather than app.order.id. Remediation: namespace everything the conventions do not define.
Unbounded attribute values. Error signature: spans of many kilobytes each. Root cause: statements, bodies or traces in attributes. Remediation: an attribute length limit, and summaries instead of payloads.
Renaming conventions mid-stream. Error signature: dashboards that show a cliff where a metric drops to zero and another appears. Root cause: an instrumentation upgrade that moved to newer attribute names. Remediation: read the instrumentation's changelog before upgrading, and update queries alongside the upgrade.
Function names as span names. Error signature: span names that change with every refactor. Root cause: naming by implementation. Remediation: name by the operation the code performs.
Frequently Asked Questions
Why must span names be low-cardinality?
Backends group spans by name to compute latency percentiles, error rates and throughput per operation. A name that contains an identifier produces one group per identifier, so no group has enough data to be useful, and backends that index span names can be overwhelmed.
What should an HTTP server span be named?
The method and route template, such as GET /orders/{order_id}. The template is fixed for every request to that endpoint; the actual path with its identifier belongs in the url.path attribute.
What are semantic conventions?
OpenTelemetry's specification of standard attribute names and values for common operations — HTTP, databases, messaging, RPC, cloud resources. Following them means a backend, a dashboard or another team's query understands your spans without translation.
What about attributes the conventions do not define?
Put them under a namespace for your organisation or domain, such as app. or the company name. The conventions keep growing, and an unnamespaced attribute may one day collide with a standard one that means something different.