Enterprise — SSO, SAML & support
obseria.io
Back to Blog
OpenTelemetry

OpenTelemetry semantic conventions: the missing manual for distributed systems teams

Semantic conventions are the unsung hero of OpenTelemetry — and the source of most cross-service correlation failures. This guide covers the full attribute namespace, the conventions that matter most in production, common pitfalls, and how to enforce consistency across polyglot services at scale.

MB

Marco Bietti

Platform Engineer, obseria.io

July 3, 202613 min read

What semantic conventions actually are (and why they matter)

OpenTelemetry gives you the plumbing to emit spans, metrics, and logs from any language. But plumbing without standards is chaos. If your Python service names the HTTP status attribute http_status, your Go service names it status_code, and your Java service names it response.status, you cannot write a single query that works across all three. You cannot build dashboards that span services. You cannot correlate an error in one service with the upstream call that caused it.

Semantic conventions solve this by defining a shared vocabulary: a set of standardised attribute names, their types, their cardinality expectations, and the conditions under which they are required versus optional. When every service in your organisation follows the same conventions, your observability backend can automatically correlate traces across service boundaries, build topology maps, and surface patterns at the fleet level.

Note:As of OpenTelemetry 1.26 (released April 2026), semantic conventions reached Stable status for HTTP, database, messaging, and RPC. The gen_ai conventions for LLM workloads reached Experimental status. Stable conventions will not introduce breaking changes.

The attribute namespace: how it's structured

OTel attribute names follow a hierarchical dot-notation namespace. The top-level prefix identifies the signal domain:

  • http.*

    HTTP client and server requests: method, URL, status code, route.

  • db.*

    Database calls: system, name, operation, statement (sanitised).

  • messaging.*

    Message queues and brokers: system, destination, operation, message ID.

  • rpc.*

    RPC calls (gRPC, Thrift, Connect): service, method, system, status code.

  • gen_ai.*

    LLM inference: system, model, prompt/completion tokens, operation.

  • cloud.*, k8s.*, host.*

    Infrastructure resource attributes: provider, cluster, namespace, node, pod.

The service.* and telemetry.* namespaces are reserved for resource attributes — metadata that describes the source of telemetry rather than the operation being traced.

HTTP conventions: the most commonly wrong attributes

HTTP conventions have changed significantly between OTel spec versions 1.20 and 1.26. Many teams are still emitting the old attribute names, which means their data does not match what their observability vendor expects. The key changes:

diff
# Old (OTel < 1.20) — deprecated, do not use
- http.method          → http.request.method
- http.url             → url.full
- http.target          → url.path + url.query
- http.host            → server.address + server.port
- http.status_code     → http.response.status_code
- http.scheme          → url.scheme
- net.peer.name        → server.address
- net.peer.port        → server.port

# New (OTel >= 1.20, Stable in 1.26)
+ http.request.method
+ url.full
+ url.path
+ url.query
+ server.address
+ server.port
+ http.response.status_code
+ url.scheme

The most impactful attribute to get right is http.route — the parameterised path template, not the concrete URL. Without it, your trace data will have a cardinality explosion: /users/123, /users/456, /users/789 are three separate series instead of one.

python
# ✅ Correct: parameterised route, not the concrete URL
from opentelemetry import trace

span = trace.get_current_span()
span.set_attribute("http.route", "/users/{user_id}")         # template
span.set_attribute("http.request.method", "GET")
span.set_attribute("http.response.status_code", 200)
span.set_attribute("server.address", "api.example.com")

# ❌ Wrong: concrete URL causes cardinality explosion
# span.set_attribute("url.full", "https://api.example.com/users/12345")
Warning:Never put user IDs, UUIDs, or any high-cardinality identifier directly into span names or attribute values that feed into metrics. High-cardinality spans create millions of unique series in your metrics backend, crashing query performance and ballooning storage costs. Use http.route as your grouping key; put the actual ID in a separate low-traffic trace attribute.

Database conventions: capturing queries without leaking PII

Database spans are some of the most operationally valuable traces in your system — and some of the most dangerous from a data governance perspective. The db.statement attribute captures the SQL query, which is essential for N+1 detection and slow query analysis, but can expose PII if queries contain bound parameter values in plaintext.

yaml
# Key database span attributes (OTel 1.26 stable)

db.system:            postgresql          # required: mongodb, mysql, redis, etc.
db.name:              users_db            # database name
db.operation.name:    SELECT              # INSERT, UPDATE, DELETE, CALL
db.collection.name:   users               # table or collection

# Sanitise the statement — replace literals with placeholders
db.query.text:        "SELECT * FROM users WHERE id = ?"
#                     NOT "SELECT * FROM users WHERE id = '12345'"

# Connection info (server.* namespace since OTel 1.20)
server.address:       db.prod.internal
server.port:          5432

# Performance metadata
db.response.rows_affected: 1

Use your OTel SDK's built-in sanitisation. Both the Python SQLAlchemy instrumentor and the Java JDBC instrumentor support statement sanitisation via configuration — enabling it replaces all literal values with ? or $N placeholders automatically. Never implement your own regex-based sanitiser; the edge cases around string literals and nested quotes will defeat you.

Messaging conventions: Kafka, RabbitMQ, and async tracing

Async messaging is where distributed tracing gets genuinely hard. A Kafka consumer processing a message published 200ms ago needs to link its span back to the producer span — across a process boundary, potentially across a different service, without an HTTP connection to carry the trace context.

OTel solves this with trace context propagation via message headers. The producer injects the W3C TraceContext into the Kafka message headers; the consumer extracts it and creates a child span. The entire flow — produce, queue, consume, process — appears as a single trace.

python
# Producer: inject trace context into Kafka headers
from opentelemetry.propagate import inject
from opentelemetry import trace

tracer = trace.get_tracer("payment-service")

with tracer.start_as_current_span("kafka.produce",
    kind=trace.SpanKind.PRODUCER) as span:

    headers = {}
    inject(headers)     # injects traceparent, tracestate

    span.set_attribute("messaging.system", "kafka")
    span.set_attribute("messaging.destination.name", "payment-events")
    span.set_attribute("messaging.operation.name", "publish")
    span.set_attribute("messaging.message.id", message_id)

    producer.produce(
        topic="payment-events",
        value=payload,
        headers=list(headers.items()),
    )
python
# Consumer: extract trace context from Kafka headers
from opentelemetry.propagate import extract
from opentelemetry import trace

tracer = trace.get_tracer("fulfillment-service")

def process_message(msg):
    headers = dict(msg.headers())
    ctx = extract(headers)   # restores trace context from producer

    with tracer.start_as_current_span("kafka.consume",
        context=ctx,
        kind=trace.SpanKind.CONSUMER) as span:

        span.set_attribute("messaging.system", "kafka")
        span.set_attribute("messaging.destination.name", "payment-events")
        span.set_attribute("messaging.operation.name", "process")
        span.set_attribute("messaging.consumer.group.name", "fulfillment-workers")

        # Now process the message — this span is a child of the producer span

Resource attributes: the foundation of service correlation

Resource attributes are the metadata that describe what emitted the telemetry — not what the telemetry is measuring. They are set once at SDK initialisation and attached to every span, metric, and log record. Getting them right is the foundation of fleet-level observability.

python
# Python: configure resource attributes at startup
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.semconv.resource import ResourceAttributes

resource = Resource.create({
    # Required — used by every OTel backend for routing and filtering
    ResourceAttributes.SERVICE_NAME:       "checkout-api",
    ResourceAttributes.SERVICE_VERSION:    "v2.14.0",
    ResourceAttributes.SERVICE_NAMESPACE:  "payments",

    # Deployment environment
    ResourceAttributes.DEPLOYMENT_ENVIRONMENT: "production",

    # Kubernetes resource attributes (auto-detected by k8s operator)
    ResourceAttributes.K8S_NAMESPACE_NAME: "payments",
    ResourceAttributes.K8S_DEPLOYMENT_NAME: "checkout-api",
    ResourceAttributes.K8S_POD_NAME:        os.environ["POD_NAME"],
    ResourceAttributes.K8S_NODE_NAME:       os.environ["NODE_NAME"],

    # Cloud provider metadata
    ResourceAttributes.CLOUD_PROVIDER:      "aws",
    ResourceAttributes.CLOUD_REGION:        "eu-west-1",
    ResourceAttributes.CLOUD_AVAILABILITY_ZONE: "eu-west-1a",
})
Tip:In Kubernetes, use the OTel Operator's resource attribute injection (opentelemetry.io/inject-python: "true") to automatically populate k8s.* resource attributes from the Pod spec via the Downward API. This eliminates a whole class of service identity bugs where the wrong pod name appears in traces.

The gen_ai conventions: LLM observability standardised

The gen_ai.* semantic conventions, introduced in OTel 1.24 and iterated through 1.26, provide a standardised schema for observing LLM inference calls. They cover OpenAI, Anthropic, Google Vertex, Amazon Bedrock, and any custom model endpoint.

python
# gen_ai span attributes for an OpenAI completion
from opentelemetry import trace

with tracer.start_as_current_span("openai.chat") as span:
    span.set_attribute("gen_ai.system",                  "openai")
    span.set_attribute("gen_ai.request.model",           "gpt-4o")
    span.set_attribute("gen_ai.request.max_tokens",      1024)
    span.set_attribute("gen_ai.request.temperature",     0.7)

    response = openai_client.chat.completions.create(...)

    span.set_attribute("gen_ai.response.model",          response.model)
    span.set_attribute("gen_ai.response.finish_reasons", ["stop"])
    span.set_attribute("gen_ai.usage.input_tokens",      response.usage.prompt_tokens)
    span.set_attribute("gen_ai.usage.output_tokens",     response.usage.completion_tokens)
    span.set_attribute("gen_ai.usage.total_tokens",      response.usage.total_tokens)

    # Do NOT log prompt/completion text as span attributes
    # Use OTel Events for prompt/response capture (opt-in, PII-aware)

The conventions deliberately separate token counts (span attributes — always collected) from prompt and completion text (span events — opt-in, privacy-sensitive). obseria.io honours this split: token usage metrics are collected by default; prompt capture requires an explicit opt-in flag in the SDK configuration.

Enforcing conventions across polyglot services

The hardest part of semantic conventions is not understanding them — it is enforcing them consistently across dozens of teams using different languages and frameworks. Three mechanisms work in practice:

  • 1

    OTel Collector attribute transformation

    Use the Collector's transform processor to rename old attribute keys to new ones at ingest time. This gives you a migration path without requiring all teams to update simultaneously.

  • 2

    Shared instrumentation library

    Create a thin wrapper around the OTel SDK for each language your organisation uses. The wrapper enforces required attributes, applies consistent resource configuration, and exposes a simplified API that makes correct instrumentation easier than incorrect.

  • 3

    Convention linting in CI

    Run a span schema validator as part of your integration test suite. It checks that spans emitted by the service include required attributes, use the correct namespace, and do not include high-cardinality values in disallowed positions.

yaml
# OTel Collector: migrate old HTTP attribute names to new ones
processors:
  transform/http-attributes:
    trace_statements:
      - context: span
        statements:
          # Rename deprecated http.method → http.request.method
          - set(attributes["http.request.method"],
              attributes["http.method"])
              where attributes["http.method"] != nil
          - delete_key(attributes, "http.method")

          # Rename deprecated http.status_code → http.response.status_code
          - set(attributes["http.response.status_code"],
              attributes["http.status_code"])
              where attributes["http.status_code"] != nil
          - delete_key(attributes, "http.status_code")

Custom attributes: the right way to extend

You will inevitably need to add attributes that are not in any OTel convention — business identifiers, feature flags, experiment IDs, tenant context. The rules for custom attributes:

  • Use a namespace prefix unique to your organisation (e.g. com.mycompany.* or app.*). Never add attributes without a prefix — future OTel conventions may collide with your names.
  • Document every custom attribute: its name, type, allowed values, which services emit it, and whether it is PII-adjacent.
  • Keep cardinality in mind: user.id is fine as a trace attribute (one per span), but never acceptable as a metric label.
  • Register custom attributes in your observability backend so they are indexed and searchable. An attribute that is not indexed cannot be filtered on — it is invisible.
Note:obseria.io automatically detects attribute namespaces and flags spans that mix OTel standard attributes with undeclared custom attributes. The Schema Validator in the obseria.io UI shows you a per-service convention compliance score and highlights the specific attributes causing compliance failures — so you can fix problems before they affect dashboard queries.
MB

Marco Bietti

Platform Engineer · obseria.io

Marco works on obseria.io's ingest pipeline and OTel compatibility layer. He is a contributor to the OpenTelemetry Collector and co-authored obseria.io's internal semantic convention enforcement framework, which is now open-sourced as otel-lint.

Convention compliance out of the box.

obseria.io validates semantic conventions at ingest, flags violations, and shows you a per-service compliance score — so bad data never silently corrupts your dashboards.