OpenTelemetry Semantic Conventions for HTTP and RPC Spans
An HTTP server span needs http.request.method, url.path, http.response.status_code, and http.route; an HTTP client span needs the method, url.full, server.address, and server.port; a gRPC span needs rpc.system, rpc.service, rpc.method, and rpc.grpc.status_code — and the span name must be the low-cardinality template, never the resolved URL.
Context and when it matters
Backends do more with a span than draw a bar in a waterfall. Service maps, RED metric dashboards, latency-by-endpoint views, and error taxonomies are all derived automatically — but only from attributes the backend recognises. A span carrying endpoint: "/orders/8fa21c" and status: "fail" renders perfectly in the trace view and contributes nothing to any of those derived features.
This page is the reference for the two conventions that cover the overwhelming majority of spans in a typical fleet: HTTP and RPC. It matters most in three situations — bringing hand-instrumented services in line with auto-instrumented ones, migrating a fleet that predates the stable HTTP conventions, and debugging why a service is missing from the service map.
The attribute sets, side by side
The three span shapes overlap but are not interchangeable. span.kind distinguishes them, and the peer attributes mean different things on each side.
Span naming is part of the convention
The span name is a low-cardinality identity, not a description. The rules:
- HTTP server:
{method} {http.route}— for exampleGET /orders/{orderId}. If the route template is unavailable, use the method alone. Never interpolate the ID. - HTTP client:
{method}alone. The target lives inserver.address, and using the URL as the name creates one span name per distinct URL. - gRPC:
{rpc.service}/{rpc.method}, exactly as it appears on the wire:checkout.v1.CheckoutService/PlaceOrder.
A backend groups latency by span name, so a name containing an ID produces one group per request and no usable percentiles.
Implementation
# FastAPI — a hand-instrumented server span that follows the convention
from opentelemetry import trace
from opentelemetry.trace import SpanKind, StatusCode
tracer = trace.get_tracer("app.http")
@app.middleware("http")
async def convention_middleware(request, call_next):
route = request.scope.get("route")
# The TEMPLATE, e.g. "/orders/{order_id}" — bounded by endpoint count.
template = getattr(route, "path", None)
name = f"{request.method} {template}" if template else request.method
with tracer.start_as_current_span(name, kind=SpanKind.SERVER) as span:
span.set_attribute("http.request.method", request.method)
span.set_attribute("url.path", request.url.path)
span.set_attribute("url.scheme", request.url.scheme)
# server.* on a SERVER span describes THIS service, not the caller.
span.set_attribute("server.address", request.url.hostname)
if template:
span.set_attribute("http.route", template)
# Caller identity belongs in client.address, and only when you need it.
if request.client:
span.set_attribute("client.address", request.client.host)
response = await call_next(request)
span.set_attribute("http.response.status_code", response.status_code)
if response.status_code >= 500:
# 5xx is an error; 4xx is the client's problem, not a span error.
span.set_attribute("error.type", str(response.status_code))
span.set_status(StatusCode.ERROR)
return response
The client side inverts the peer attributes:
// Node.js — an outbound call, with server.* describing the peer
const span = tracer.startSpan('GET', { kind: SpanKind.CLIENT });
span.setAttribute('http.request.method', 'GET');
// url.full may contain query strings — high cardinality, search-only.
span.setAttribute('url.full', 'https://payments.internal/v2/charges?limit=50');
// These two are what the backend uses to draw the edge to the peer service.
span.setAttribute('server.address', 'payments.internal');
span.setAttribute('server.port', 443);
// Retries get their own attribute rather than their own span name.
span.setAttribute('http.request.resend_count', 1);
And gRPC drops the URL vocabulary entirely:
# gRPC server interceptor — the RPC convention
with tracer.start_as_current_span(
"checkout.v1.CheckoutService/PlaceOrder", kind=SpanKind.SERVER) as span:
span.set_attribute("rpc.system", "grpc")
span.set_attribute("rpc.service", "checkout.v1.CheckoutService")
span.set_attribute("rpc.method", "PlaceOrder")
span.set_attribute("server.address", "checkout.internal")
span.set_attribute("server.port", 50051)
code = handle(request)
# Numeric gRPC code — 0 OK, 4 DEADLINE_EXCEEDED, 14 UNAVAILABLE.
span.set_attribute("rpc.grpc.status_code", int(code))
if code != 0:
span.set_attribute("error.type", code.name)
span.set_status(StatusCode.ERROR)
Migrating from the pre-1.21 keys
Most fleets carry a mix. The map below is the complete set of renames that matter for HTTP, and each row is a one-line Collector statement.
# collector-config.yaml — rename in flight, so services migrate at their own pace
processors:
transform/http_semconv:
error_mode: ignore
trace_statements:
- context: span
statements:
- set(attributes["http.request.method"], attributes["http.method"]) where attributes["http.method"] != nil
- delete_key(attributes, "http.method")
- set(attributes["http.response.status_code"], attributes["http.status_code"]) where attributes["http.status_code"] != nil
- delete_key(attributes, "http.status_code")
- set(attributes["url.full"], attributes["http.url"]) where attributes["http.url"] != nil
- delete_key(attributes, "http.url")
- set(attributes["server.address"], attributes["net.peer.name"]) where attributes["net.peer.name"] != nil
- delete_key(attributes, "net.peer.name")
During a cutover you can have SDKs emit both sets with OTEL_SEMCONV_STABILITY_OPT_IN=http/dup, then drop the legacy keys in the Collector once dashboards have moved. Turn it off when the migration finishes — dual emission adds roughly 120 bytes to every HTTP span.
What each key actually buys you
It is easier to keep a convention when you know which backend feature breaks without each key. The mapping is close to one-to-one:
The dashed row is the one to treat differently: those keys are valuable when investigating a specific request and dangerous as aggregation dimensions, because their value space is unbounded.
Decision criteria
Use this to decide what to set on a given span:
- Is it handling an inbound request?
kind=SERVER, sethttp.route, and letserver.addressdescribe this service. - Is it making an outbound request?
kind=CLIENT, seturl.fullandserver.address/server.portfor the peer. Skiphttp.route; it does not exist on the client side. - Is it gRPC or another RPC protocol? Use the
rpc.*keys and dropurl.*entirely. Setrpc.grpc.status_codeas an integer. - Is a 4xx an error? By convention, no. Client errors set the status code attribute but leave span status unset; only server-side failures set
StatusCode.ERROR. See recording exceptions and error status on spans. - Is the value unbounded? It can be a span attribute for search, but must never become a metric dimension — see controlling span attribute cardinality.
Where the conventions leave you room
The conventions are precise about names and loose about coverage, and that gap is where teams disagree. Three areas come up in nearly every review.
Which attributes are truly required. The specification marks a handful of keys required, several recommended, and many opt-in. In practice the required set is what makes a span valid; the recommended set is what makes it useful. A server span with only http.request.method is conformant and nearly worthless — it cannot be grouped by endpoint. Treat http.route on server spans and server.address on client spans as mandatory in your own house style even though the specification calls them recommended, because every derived backend view depends on them.
How much of the URL to keep. url.full is recommended on client spans, and on internal service-to-service calls it is genuinely useful. On calls carrying credentials in query parameters it is a liability. The workable policy is to keep url.full for internal peers, record only url.path and server.address for external ones, and strip url.query in the Collector for anything crossing a trust boundary.
What to do about protocols the conventions do not cover. GraphQL, WebSocket, and proprietary binary protocols have no stable convention. The pragmatic approach is to borrow the closest existing vocabulary rather than invent a parallel one: a GraphQL request over HTTP still has http.request.method and http.route, plus a graphql.operation.name for the resolver identity. Borrowing keeps the span visible in the generic HTTP dashboards while adding what is specific to the protocol.
The common failure in all three areas is inconsistency between services rather than a wrong choice. Whatever policy you pick, write it down and enforce it in the Collector, so a new service inherits it rather than re-litigating it.
Common pitfalls
http.routeset from the resolved path. The single most damaging mistake here: it turns a twelve-value dimension into a million-value one and makes every endpoint dashboard useless. If the framework cannot give you the template, leave the attribute unset rather than approximating it.server.addressset to the local host on client spans. The service map then draws every edge pointing back at the caller, and peer services appear disconnected.- Query strings in
url.fullon public endpoints. Tokens, session IDs, and email addresses routinely live in query parameters and end up permanently stored. Strip the query in the Collector unless you have a specific reason to keep it. - gRPC spans named after the transport. A span named
POSTfor a gRPC call loses the method identity that makes RPC traces readable; useservice/method.
Related
- Controlling span attribute cardinality — keeping the convention’s high-cardinality keys out of your metrics
- Recording exceptions and error status on spans — the error half of the convention
- Instrumenting gRPC services with OpenTelemetry — applying the RPC convention through interceptors
↑ Back to Span Attributes and Semantic Conventions