Configuring Views and Aggregation in OpenTelemetry Metrics

A metric is recorded in one place and consumed in another, and the two rarely agree about bucket boundaries or which attributes matter. Views are the mechanism for resolving that without editing the recording code — which matters most for instruments a library created and you cannot change. This page covers the four things Views actually do. It builds on the OpenTelemetry metrics SDK, part of the Python metrics and instrumentation section.

Where a View sits, and the four things it can change The path from a recorded measurement to an exported metric stream, with the View in the middle. On the left an instrument — created either by your code or by a library you depend on — records a value with a set of attributes. In the middle the SDK looks for a View whose selector matches that instrument by name, by type, or by the instrumentation scope that created it. If one matches, it can apply four transformations: replace the aggregation, most usefully with explicit histogram buckets chosen for an SLO; restrict the attribute set to an allowlist, dropping the rest before aggregation so they never become series; rename the stream so an awkward library metric name becomes the one your dashboards expect; or drop the instrument entirely so it produces nothing at all. On the right the resulting stream reaches every configured exporter identically, whether that is OTLP or a Prometheus endpoint, because the View operates below both. instrument → View → stream → every exporter alike the instrument yours, or a library's records a value + attributes a matching View explicit buckets attribute allowlist rename the stream drop it entirely the metric stream OTLP · Prometheus both see the same result why this matters more than it sounds most cardinality problems come from instruments you did not write — a library recording a full URL, a driver labelling by host a View fixes those without forking the library, and the fix is one declaration reviewed alongside the rest of your configuration the alternative is a Collector-side filter, which works and is further from the code that has the problem
Most cardinality problems come from instruments you did not write. A View is how you fix them without forking the library.

Prerequisites

pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"
export OTEL_SERVICE_NAME=orders-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_METRIC_EXPORT_INTERVAL=15000

Implementation

Step 1 — Set explicit buckets on a latency instrument. The SDK's default ladder is general-purpose; an SLO needs a boundary on the objective.

from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

latency_view = View(
    instrument_name="http.server.request.duration",
    aggregation=ExplicitBucketHistogramAggregation(
        boundaries=[0.010, 0.025, 0.050, 0.100, 0.150, 0.250, 0.400, 0.600, 1.0, 2.5],
    ),
)

provider = MeterProvider(
    metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter(insecure=True))],
    views=[latency_view],
)

The 0.250 boundary is the SLO threshold, for the reasons in choosing histogram buckets for latency SLOs.

Step 2 — Drop attributes you cannot stop being recorded. attribute_keys is an allowlist: everything not listed is discarded before aggregation.

from opentelemetry.sdk.metrics.view import View

# A library records http.url in full. Keep the route, drop the rest.
bound_cardinality = View(
    instrument_name="http.client.request.duration",
    attribute_keys={"http.request.method", "server.address", "http.response.status_code"},
)

The attribute never becomes a series, which means the cardinality is bounded at the SDK rather than at the Collector — cheaper, and closer to the code that has the problem.

Step 3 — Rename an awkward stream. Useful when a library's metric name does not match your naming convention and your dashboards already exist.

rename = View(
    instrument_name="requests.duration",           # what the library calls it
    name="http.client.request.duration",           # what your dashboards expect
    description="Outbound HTTP request duration",
)

Step 4 — Disable an instrument entirely. DropAggregation produces no stream at all.

from opentelemetry.sdk.metrics.view import View, DropAggregation

silence = View(
    instrument_name="db.client.connections.usage",   # noisy, and not yours to remove
    aggregation=DropAggregation(),
)

Prefer this to filtering later: a dropped instrument costs nothing to aggregate, nothing to export and nothing to store, while a Collector-side filter has already paid for all three.

Three places to drop a series, and what each has already cost An unwanted metric series removed at three different points, with the work already performed at each. Dropped by a View in the SDK, nothing has happened yet: the measurement is discarded before aggregation, so no memory is allocated for the series, no bytes are serialised, no network hop is taken and no storage is touched. Dropped by a processor in the Collector, the series has already been aggregated in the application, serialised into a payload, sent across a network hop and parsed at the other end, so all of that cost has been paid and only storage is saved. Dropped by a recording or relabel rule in the backend, everything above has been paid plus ingestion and indexing, and typically the raw series is still stored for the retention period regardless. The conclusion drawn is that the cost of the same decision differs by orders of magnitude depending on where it is made, and the SDK is nearly always the cheapest place — with the caveat that it is also the place that requires a redeploy to change. the same series, dropped in three places a View, in the SDK nothing has happened yet no aggregation, no bytes, no hop, no storage a Collector processor already aggregated, serialised, sent and parsed only storage is saved a backend rule all of the above, plus ingestion and indexing usually stored anyway the trade that goes the other way the SDK is cheapest and needs a redeploy to change · the Collector is a config reload · the backend is a query edit so a permanent decision belongs in a View, and an experiment belongs further right
The cheapest place to drop a series is also the slowest to change. Permanent decisions belong in a View; experiments belong in the Collector.

Step 5 — Register everything on the provider. Views are applied at stream creation, so they must exist before the instruments do.

provider = MeterProvider(
    resource=RESOURCE,
    metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter(insecure=True))],
    views=[latency_view, bound_cardinality, rename, silence],
)
metrics.set_meter_provider(provider)

A View added after an instrument has been created has no effect on it, and nothing warns about that — which is why the whole set belongs in the same startup function.

What an attribute allowlist does to the series count One instrument's series count before and after an attribute allowlist. Before, the library records four attributes on every measurement: the HTTP method with five values, the route with twelve, the response status with six, and the full request URL, which is effectively unbounded because it contains query strings and path parameters. The resulting series count is not a number anyone can budget — it grows with traffic rather than with the service's shape. After a View restricts the attribute set to method, route and status, the URL is discarded before aggregation and never becomes a series at all, leaving five times twelve times six, which is three hundred and sixty: a fixed number that does not change as traffic grows. The note added is that the recording code is unchanged, so the URL is still available on the corresponding span, where high cardinality is not a problem. attribute_keys = {method, route, status} what the library records http.request.method — 5 values http.route — 12 values http.response.status_code — 6 url.full — unbounded series: grows with traffic the View allowlist what the exporter sees method · route · status url.full never becomes a series 5 × 12 × 6 = 360 and it stays 360 next year the recording code is untouched — so url.full is still on the corresponding span, where high cardinality costs nothing which is the division of labour the two signals are for
The attribute is not deleted from the system — it is kept out of the metric and left on the span, where cardinality is not a constraint.

Configuration options

Selector Purpose Notes
instrument_name match one instrument supports a * wildcard
instrument_type match a whole class for example every histogram
meter_name match an instrumentation scope the way to target one library
Transformation
aggregation change the shape ExplicitBucketHistogramAggregation, DropAggregation, SumAggregation
attribute_keys allowlist everything unlisted is dropped
name rename the stream give distinct names when two Views match
description document it appears in the exported metadata

Verification

from opentelemetry import metrics

meter = metrics.get_meter("verify")
hist = meter.create_histogram("http.server.request.duration", unit="s")
for value in (0.02, 0.2, 0.24, 0.3, 1.2):
    hist.record(value, {"http.route": "/orders/{id}", "user.id": "u-9f3c"})

Expected Output (Collector debug exporter):

Histogram #0
  -> http.route: Str(/orders/{id})
  Count: 5
  Sum: 1.960
  ExplicitBounds: [0.01, 0.025, 0.05, 0.1, 0.15, 0.25, 0.4, 0.6, 1, 2.5]
  Buckets: [0, 1, 0, 0, 0, 2, 1, 0, 0, 1, 0]

Two things confirm the Views are active: the boundaries are yours rather than the SDK default, and user.id is absent although the recording code passed it — dropped by the attribute allowlist before aggregation.

Then assert it, so a later refactor cannot silently reintroduce the attribute:

def test_user_id_never_reaches_the_exporter():
    metrics_data = reader.get_metrics_data()
    for rm in metrics_data.resource_metrics:
        for sm in rm.scope_metrics:
            for metric in sm.metrics:
                for point in metric.data.data_points:
                    assert "user.id" not in point.attributes

Common mistakes

The View has no effect

Error signature: the default buckets are still exported despite a View being defined. Root cause: the View was created after the MeterProvider, or the selector does not match — most often because the instrument name differs by a dot or a suffix. Remediation: pass Views to the constructor, and print the instrument names the SDK actually sees before writing the selector.

Two Views produce one stream

Error signature: a fine-grained and a coarse stream were configured, and only one appears. Root cause: both Views produce a stream with the same name, so they collide at the exporter. Remediation: give each View an explicit distinct name.

Cardinality is fixed in the Collector instead

Error signature: the Collector's memory limiter engages regularly under normal load. Root cause: the series were created, aggregated, serialised and sent before being dropped. Remediation: move the permanent drops into Views and keep the Collector filter for experiments.

Selectors, and how specific to be

A View's selector decides which instruments it applies to, and the choice between the available criteria is mostly about how much future behaviour you want to constrain.

By instrument name is the most common and the most precise. It applies to exactly one stream, which makes the intent obvious to a reader and means an instrument renamed by a dependency upgrade silently stops matching — a failure mode worth knowing about, and one that a startup assertion catches.

By instrument type applies to a whole class: every histogram, every counter. This is the right selector for a policy that is genuinely about the shape rather than about a specific metric — "every histogram in this service uses these boundaries" is a reasonable default that individual Views can then override.

By meter name applies to everything created by one instrumentation scope, which is the way to target a single library's instruments without naming each one. When a dependency emits a dozen metrics and you want the same attribute allowlist on all of them, this is the selector that expresses that in one entry.

With a wildcard on the name, which is useful for a family — http.client.* — and worth using sparingly, because a wildcard that grows to match something unintended is harder to notice than a name that stops matching.

# every histogram gets a sensible default ladder
View(instrument_type=Histogram,
     aggregation=ExplicitBucketHistogramAggregation(boundaries=DEFAULT_LADDER))

# and one specific metric overrides it
View(instrument_name="http.server.request.duration",
     aggregation=ExplicitBucketHistogramAggregation(boundaries=SLO_LADDER))
Selector Scope Best for
instrument_name one stream a specific decision
instrument_name with * a family consistent treatment of related metrics
instrument_type a class a service-wide default
meter_name one library policy for a dependency's metrics

Temporality is a reader concern, not a View one

A related setting that often gets confused with Views: aggregation temporality — cumulative or delta — is configured on the metric reader or the exporter, not on a View. A View changes the shape of a stream; temporality changes how successive exports relate to each other.

The distinction matters when debugging, because a counter behaving oddly across restarts is a temporality question and no View will affect it. Cumulative counters restart at zero and the query layer compensates; delta counters lose only the interval in flight. Set it explicitly for the destination rather than relying on a default, as covered in bridging Prometheus metrics into OpenTelemetry.

One practical note on ordering: Views are evaluated against each instrument as its stream is created, and where several match, each produces its own stream. That makes a broad default plus a narrow override a matter of naming the streams distinctly rather than of precedence — there is no first-match-wins rule to rely on.

Keeping Views reviewable

Views encode policy that is invisible from the call site: someone reading histogram.record(elapsed, {"user.id": uid}) has no way to know the attribute is dropped before export. That asymmetry is the price of being able to fix instrumentation you do not own, and two habits keep it from becoming confusing.

Keep every View in one module next to the provider construction, so the whole policy is readable at once. And comment each one with why rather than what — "url.full is unbounded; kept on the span instead" explains a decision that the code alone cannot, and it is the note that stops someone removing the View six months later because it looked redundant.

A startup assertion is the third habit worth having: check that each View's selector matches at least one instrument, and log a warning when it does not. A View that matches nothing is silent, and it is exactly what a renamed instrument produces.

Frequently Asked Questions

What is a View for?

Changing how an instrument's measurements become a metric stream, without changing the code that records them. That covers four things in practice: custom histogram buckets, dropping attributes to reduce cardinality, renaming a stream, and disabling an instrument entirely. The common thread is that all four are decisions the consumer of the metric should be able to make, and the instrumented code — especially a library's — is often not yours to edit.

Can I add a View after the MeterProvider is created?

No. Views are supplied to the provider's constructor and are applied when a stream is created for an instrument, so a View registered later does not affect instruments that already exist. In practice this means Views belong in the same startup function that builds the provider, which is also where they are easiest to review.

How do I drop an attribute without touching the recording code?

Give the View an attribute_keys set containing only the attributes you want to keep. Everything else is dropped before aggregation, so the series count collapses to the combinations of the keys you listed. That is the standard remedy when a library records something useful for tracing and ruinous for metrics — a user id, a full URL — and you cannot change it.

What happens when two Views match the same instrument?

You get two streams, which is a feature rather than a conflict: a fine-grained stream with all attributes for a short-retention system and a coarse one with attributes dropped for long-term storage. Give each View a distinct name, or the two streams collide in the exporter and the result depends on which arrived last.

Do Views work for the Prometheus exporter too?

Yes — Views operate at the SDK level, before any exporter sees the data, so they apply identically whether you export over OTLP or expose a Prometheus endpoint through the OpenTelemetry Prometheus exporter. That is one of the practical arguments for the OpenTelemetry SDK: the reshaping rules live in one place regardless of where the data ends up.