Skip to content

Understand Attribute Placement and Cardinality

This guide covers how motel models resource attributes and span attributes, how to experiment with moving attributes between levels, and how to use attribute generators to explore cardinality impact before deploying changes to production.

Prerequisites

  • motel installed
  • A topology file (see Model your services)
  • A tracing backend or the --stdout flag for local inspection

Resource attributes vs span attributes

motel distinguishes two levels of attributes, matching the OpenTelemetry data model:

  • Resource attributes are defined under services.<name>.resource_attributes. They describe the service itself and are attached to the OTel resource — stored once per service, not per span. These are static string key-value pairs.
  • Span attributes are defined under services.<name>.operations.<op>.attributes. They describe individual operations and can vary per span using attribute generators. There is also a service-level attributes field that adds static key-value pairs to every span from the service.
services:
  gateway:
    resource_attributes:               # OTel resource — stored once per service
      deployment.environment: production
      service.namespace: demo
    operations:
      GET /users:
        duration: 30ms +/- 10ms
        attributes:                    # span attributes — stored per span
          http.request.method:
            value: GET
          http.response.status_code:
            values: {200: 95, 404: 3, 500: 2}

Resource attributes appear once per service resource in the exported telemetry. Span attributes appear on each individual span. This distinction matters for storage cost, query performance, and how your backend indexes data.

See docs/examples/attribute-placement.yaml for a runnable example that uses both levels on the same service.

Experiment: move an attribute between levels

A practical way to understand the difference is to move an attribute from one level to the other and observe the result.

Start with a resource attribute

Create a file called placement-test.yaml:

version: 1

services:
  api:
    resource_attributes:
      deployment.environment: staging
    operations:
      handle:
        duration: 10ms

traffic:
  rate: 10/s

Generate traces and inspect the output:

motel run --stdout --duration 3s placement-test.yaml | head -20

Notice that deployment.environment is attached to the OTel resource for the api service. In the --stdout JSON output, resource attributes appear in the Resource field. When sent to a real backend via --endpoint, resource attributes are stored once per service — not duplicated on every span.

Move it to a span attribute

Now move deployment.environment from the service level to the operation level:

version: 1

services:
  api:
    operations:
      handle:
        duration: 10ms
        attributes:
          deployment.environment:
            value: staging

traffic:
  rate: 10/s

Run the same command:

motel run --stdout --duration 3s placement-test.yaml | head -20

In the --stdout JSON, deployment.environment now appears in the span's Attributes array rather than the Resource field. When sent to a real backend, the difference is significant: resource attributes are stored once per service, while span attributes are stored on every individual span. Placing a constant value at the span level increases storage cost and may change how you query the attribute.

To see this distinction in practice, send the two versions to a collector and compare how your backend indexes them:

motel run --endpoint localhost:4318 --duration 10s placement-test.yaml

Rule of thumb: attributes that are constant for a service belong at the service level. Attributes that vary per request belong at the operation level.

Attribute generators and cardinality

Span attributes in motel use generators that control how many distinct values an attribute produces. This directly maps to cardinality — the number of unique values a backend must index.

Low cardinality: value and values

A value generator always produces the same string — cardinality of 1:

http.request.method:
  value: GET

A values generator picks from a fixed set with weighted probability — cardinality equals the number of choices:

http.response.status_code:
  values:
    200: 95
    404: 3
    500: 2

These are safe for most backends. The set of distinct values is small and bounded.

High cardinality: sequence

A sequence generator produces a unique value for every span:

user.id:
  sequence: "user-{n}"

This creates user-1, user-2, user-3, and so on — unbounded cardinality. Add this to a topology and send traffic to your backend to see how it handles high-cardinality attributes.

Numeric range: range

A range generator produces random integers within bounds:

http.response.content_length:
  range: [0, 50000]

Cardinality is bounded by the range size but can still be high. A range of [0, 50000] produces up to 50,001 distinct values.

Controlled distribution: distribution

A distribution generator samples from a normal distribution:

queue.depth:
  distribution:
    mean: 100
    stddev: 20

Values cluster around the mean but the theoretical range is unbounded.

Boolean: probability

A probability generator produces true/false with the given probability — cardinality of 2:

cache.hit:
  probability: 0.8

Test cardinality impact on your backend

Combine these generators in a topology to simulate realistic and adversarial attribute patterns:

version: 1

services:
  api:
    resource_attributes:
      deployment.environment: staging
    operations:
      handle:
        duration: 15ms +/- 5ms
        attributes:
          http.request.method:
            values: {"GET": 70, "POST": 20, "PUT": 10}
          user.id:
            sequence: "user-{n}"
          http.response.status_code:
            values: {200: 90, 400: 5, 500: 5}
          response.size:
            range: [100, 10000]

traffic:
  rate: 50/s

Send this to your backend and monitor:

motel run --endpoint localhost:4318 --duration 60s cardinality-test.yaml

Watch for:

  • Index growth — high-cardinality attributes like user.id cause index bloat in most tracing backends
  • Query performance — try querying by user.id vs http.request.method and compare response times
  • Storage cost — compare the data volume with and without the user.id attribute

To isolate the effect of a single attribute, run the topology twice — once with the high-cardinality attribute and once without — and compare the results in your backend.

Semantic conventions and correct placement

Instead of hand-writing well-known attributes — and risking typos or wrong placement — you can set a domain on an operation and let motel generate convention-correct attributes for you:

operations:
  GET /users:
    duration: 30ms +/- 10ms
    domain: http

The domain shorthand draws attribute names, types, and example values from the embedded OpenTelemetry semantic convention registry, so the generated attributes always match the conventions. See How motel uses OTel semantic conventions for details.

The --semconv flag points to a directory of additional semantic convention YAML files, which motel merges with the embedded conventions. Use it to make custom domains available to the domain shorthand:

motel run --stdout --semconv /path/to/semconv --duration 5s topology.yaml

Note that motel does not validate hand-written attribute names against the conventions — motel validate checks topology structure, not attribute spelling or placement. If you want convention-correct attributes, use domain; if you write attributes by hand, the names are emitted exactly as you wrote them.

Further reading