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.

Required attributes by span shape Three columns. HTTP server span, kind SERVER, named from the route template, requires the request method, URL path, response status code, and route. HTTP client span, kind CLIENT, named from the method, requires the method, full URL, server address, and server port. gRPC span requires the RPC system, service, method, and gRPC status code, and is named service slash method. span.kind decides which set applies HTTP server kind = SERVER name: "GET /orders/{id}" http.request.method url.path url.scheme http.route http.response.status_code server.address = this host HTTP client kind = CLIENT name: "GET" http.request.method url.full server.address server.port http.response.status_code server.* = the peer called gRPC / RPC kind = SERVER or CLIENT name: "pkg.Svc/Method" rpc.system rpc.service rpc.method rpc.grpc.status_code server.address no url.* keys at all Getting server.address wrong — this host on a client span, or the peer on a server span — inverts every edge in the service map.

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 example GET /orders/{orderId}. If the route template is unavailable, use the method alone. Never interpolate the ID.
  • HTTP client: {method} alone. The target lives in server.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.

Legacy to stable HTTP attribute renames Two columns joined by arrows. http.method becomes http.request.method. http.status_code becomes http.response.status_code. http.url becomes url.full. http.target splits into url.path and url.query. http.scheme becomes url.scheme. net.peer.name becomes server.address. net.peer.port becomes server.port. http.user_agent becomes user_agent.original. Pre-1.21 key → stable key legacy stable http.method http.request.method http.status_code http.response.status_code http.url url.full http.target url.path + url.query http.scheme url.scheme net.peer.name server.address net.peer.port server.port http.user_agent user_agent.original
# 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:

Which backend feature each attribute group feeds Four attribute groups on the left connect to four backend features on the right. Span kind with server address and port feeds the service map. HTTP route with request method feeds RED dashboards and latency by endpoint. Response status code with error type feeds the error taxonomy. Full URL with user agent feeds forensic search only. Drop a key, lose a feature span.kind + server.address + port service map edges http.route + http.request.method RED + latency by endpoint status_code + error.type error taxonomy + alerting url.full + user_agent.original forensic search only

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, set http.route, and let server.address describe this service.
  • Is it making an outbound request? kind=CLIENT, set url.full and server.address/server.port for the peer. Skip http.route; it does not exist on the client side.
  • Is it gRPC or another RPC protocol? Use the rpc.* keys and drop url.* entirely. Set rpc.grpc.status_code as 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.route set 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.address set 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.full on 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 POST for a gRPC call loses the method identity that makes RPC traces readable; use service/method.

Related

↑ Back to Span Attributes and Semantic Conventions