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.

What a span name is for Ten thousand requests to one endpoint are traced two ways. In the first, each span is named with the actual path including the order identifier, such as GET /orders/8812 and GET /orders/8813. The backend creates ten thousand operation groups, each containing one span, so there is no p99 latency for the endpoint, no error rate, and the operations list is dominated by near-duplicates. In the second, every span is named GET /orders/{order_id}, with the actual identifier in the url.path attribute. The backend creates one group of ten thousand spans, from which p50, p99, error rate and throughput are computed, and any individual request can still be found by filtering on the attribute. The note records that the identifier is not lost by moving it to an attribute; it is simply moved to the place where varying values belong. 10 000 requests to one endpoint named with the identifier GET /orders/8812 GET /orders/8813 GET /orders/8814 … 10 000 groups of one span — no p99, no error rate, no operation list named with the template, identifier in an attribute GET /orders/{order_id} — one group of 10 000 p50 41 ms · p99 212 ms · 0.4% errors url.path = "/orders/8812" on each span — any single request still findable nothing is lost by moving the identifier — it moves to where varying values belong
A span name is a grouping key. Put the parts that vary in attributes and the name becomes something every aggregate can be computed over.

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"
  }
}
Conventions make the fleet one dataset Three services record the HTTP status code of their outbound calls. Without conventions, one uses status, one uses http_status and one uses response.code, and a query for failing outbound calls across the fleet needs three clauses and misses the fourth service somebody adds next month with yet another name. With semantic conventions, all three use http.response.status_code, and one query covers every current and future service. The same applies to the database system, the route template and the messaging destination. The note records that the conventions' value grows with the number of services and is close to zero for a single one, which is why teams building their first service often skip them and regret it later. the HTTP status of outbound calls, in three services each service's own name orders: status billing: http_status search: response.code a fleet query needs three clauses, and misses the next service semantic conventions http.response.status_code — in orders, billing, search, and every service added later worth almost nothing for one service, and more with every service added
A convention's value is proportional to the number of services that follow it. One query then covers every service, including ones that do not exist yet.

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.

Good names and bad ones A table of span names, each marked good or bad with the reason. GET /orders/{order_id} is good: a method plus the route template, one name for every order. GET /orders/8841 is bad: the identifier makes a new name per order. SELECT orders is good for a database span: the operation and the table. The full SQL text as the name is bad: it is long, unbounded and may contain values. process_payment is good for a manual span: a stable operation name. process_payment for customer 42 is bad: an attribute value baked into the name. The note says the name is for grouping and the attributes are for detail. span name verdict why GET /orders/{order_id} good method + route template GET /orders/8841 bad a new name for every order SELECT orders good operation + table the full SQL text bad unbounded, may contain values process_payment good a stable operation name process_payment for customer 42 bad an attribute baked into the name the name is for grouping; attributes carry the detail if two spans doing the same work get different names, the name holds too much
A span name should be the same for every execution of the same operation. Anything that varies belongs in attributes.

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.