Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
87576cc
Added support for exclude list in attribute keys
saurabh-saraswat Jun 19, 2026
dc14a6d
corrected lint issues
saurabh-saraswat Jun 19, 2026
15256cc
Updated changelog fragment file
saurabh-saraswat Jun 19, 2026
65a652b
Update .changelog/5332.fixed
saurabh-saraswat Jul 1, 2026
498b25b
Renamed changelog fragment
saurabh-saraswat Jul 2, 2026
39ae4d8
Incorporated review comments
saurabh-saraswat Jul 6, 2026
3d15f96
Updated for comment related to assertions
saurabh-saraswat Jul 13, 2026
512dace
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 13, 2026
e5e2f3c
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 13, 2026
ac4e44a
Merge branch 'main' into exclude_attribute
xrmx Jul 14, 2026
e70b832
Updated measurement based on exclude attributes
saurabh-saraswat Jul 15, 2026
7b4046e
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 15, 2026
87d042c
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 16, 2026
aa4e89b
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 17, 2026
7a36cb9
Apply suggestion from @herin049
saurabh-saraswat Jul 21, 2026
6ae5263
Apply suggestion from @herin049
saurabh-saraswat Jul 21, 2026
8a8d139
Apply suggestion from @herin049
saurabh-saraswat Jul 21, 2026
0172b13
Incorporated review comments related type of attribute_keys, exclude_…
saurabh-saraswat Jul 21, 2026
d1b0626
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 27, 2026
70da89a
Based on review comment from Herin, refactored the code
saurabh-saraswat Jul 27, 2026
7251a05
Incorporated review comments related to testcase and cloning of measu…
saurabh-saraswat Jul 30, 2026
6e1d27b
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 30, 2026
f5a367d
Merge branch 'main' into exclude_attribute
saurabh-saraswat Jul 31, 2026
494aa4f
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 3, 2026
de4ed2c
Incorporated review comment from lzchen by materializing the iterator…
saurabh-saraswat Aug 4, 2026
4771f97
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 5, 2026
bfacf31
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 6, 2026
fbf6f3e
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 12, 2026
39d17b4
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 13, 2026
b951d97
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 14, 2026
b38d113
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 21, 2026
0b4b678
Merge branch 'main' into exclude_attribute
lzchen Aug 21, 2026
f5765e8
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 24, 2026
f29dd82
Corrected header inclusion
saurabh-saraswat Aug 24, 2026
c799615
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 25, 2026
1c5de9b
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 26, 2026
4f360ac
Merge branch 'main' into exclude_attribute
saurabh-saraswat Aug 31, 2026
093da4e
Updated failing testcase to use hash
saurabh-saraswat Aug 31, 2026
3e0a9f9
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 1, 2026
d190888
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 1, 2026
71a1985
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 2, 2026
050795a
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 4, 2026
6483e65
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 7, 2026
517a8ac
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 8, 2026
32ebe70
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 9, 2026
71b1c3d
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 15, 2026
f4622df
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 17, 2026
12454d2
Update opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py
saurabh-saraswat Sep 21, 2026
fd56da6
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 21, 2026
21c7bd5
Incorporated review comments related to specfic exception, eliminatin…
saurabh-saraswat Sep 22, 2026
7596c15
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 22, 2026
0d4bb7d
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 24, 2026
55b392f
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 25, 2026
833951d
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 29, 2026
f0bd477
Merge branch 'main' into exclude_attribute
saurabh-saraswat Sep 30, 2026
915b5f3
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 5, 2026
878213e
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 6, 2026
bb588d3
Incorprated review comment related to wildcard handling
saurabh-saraswat Oct 7, 2026
3ffd0c7
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 7, 2026
feb8082
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 13, 2026
282c0ff
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 15, 2026
f698935
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 16, 2026
cbaec00
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 21, 2026
a377e7f
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 22, 2026
ffe91e8
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 26, 2026
49295dc
Merge branch 'main' into exclude_attribute
saurabh-saraswat Oct 30, 2026
817694a
Fix View attribute filtering semantics and support exclude_attribute_…
saurabh-saraswat Oct 30, 2026
8b833f9
Preserve attributes when no filter is configured and apply include/ex…
saurabh-saraswat Oct 30, 2026
4ce41e1
Fix attribute-key matching to treat brackets/backslashes literally wh…
saurabh-saraswat Oct 30, 2026
7504ca8
Apply attribute key matcher fix for literal bracketed names and wildc…
saurabh-saraswat Oct 30, 2026
f4f73d9
Apply attribute key matcher fix inside _ViewInstrumentMatch to use th…
saurabh-saraswat Oct 30, 2026
2ac473e
Refactor test cases for attribute key matching
saurabh-saraswat Nov 2, 2026
30098da
Add tests for attribute exclusion and inclusion
saurabh-saraswat Nov 2, 2026
50e6978
Add tests for stream attribute key configurations
saurabh-saraswat Nov 2, 2026
c17881d
Refactor test cases for attribute key overlap
saurabh-saraswat Nov 2, 2026
664447c
Refactor consume_measurement test for clarity
saurabh-saraswat Nov 2, 2026
b3e54db
Refactor test_stream_attribute_keys_exact_overlap_is_allowed
saurabh-saraswat Nov 2, 2026
8ec9394
Merge branch 'main' into exclude_attribute
saurabh-saraswat Nov 3, 2026
3f522f0
Merge branch 'main' into exclude_attribute
saurabh-saraswat Nov 5, 2026
0ba56e2
Refactor test for attribute keys overlap
saurabh-saraswat Nov 6, 2026
e31b7c5
Merge branch 'main' into exclude_attribute
saurabh-saraswat Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5332.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-sdk`: add support for parameter `exclude_attribute_keys` in `View`
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,13 @@ def _create_view(config: ViewConfig) -> View:
if instrument_type is None:
raise ConfigurationError(f"Unknown instrument type: {selector.instrument_type!r}")

attribute_keys: set[str] | None = None
attribute_keys: list[str] | None = None
exclude_attribute_keys: list[str] | None = None
if stream.attribute_keys is not None:
if stream.attribute_keys.excluded:
_logger.warning(
"attribute_keys.excluded is not supported by the Python SDK View; the exclusion list will be ignored."
)
if stream.attribute_keys.excluded is not None:
exclude_attribute_keys = stream.attribute_keys.excluded
if stream.attribute_keys.included is not None:
attribute_keys = set(stream.attribute_keys.included)
attribute_keys = stream.attribute_keys.included

aggregation = None
if stream.aggregation is not None:
Expand All @@ -259,6 +258,7 @@ def _create_view(config: ViewConfig) -> View:
description=stream.description,
attribute_keys=attribute_keys,
aggregation=aggregation,
exclude_attribute_keys=exclude_attribute_keys,
aggregation_cardinality_limit=stream.aggregation_cardinality_limit,
)

Expand Down
72 changes: 68 additions & 4 deletions opentelemetry-configuration/tests/test_meter_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,11 +964,75 @@ def test_stream_attribute_keys_included(self):
)
self.assertEqual(view._attribute_keys, {"key1", "key2"})

def test_stream_attribute_keys_excluded_logs_warning(self):
def test_stream_attribute_keys_excluded_is_applied(self):
config = self._make_view_config(stream_kwargs={"attribute_keys": IncludeExclude(excluded=["key1"])})
with self.assertLogs("opentelemetry.configuration._meter_provider", level="WARNING") as log:
create_meter_provider(config)
self.assertTrue(any("excluded" in msg for msg in log.output))
meter_provider = create_meter_provider(config)
views = meter_provider._sdk_config.views
self.assertEqual(len(views), 1)
view = views[0]
self.assertEqual(view._exclude_attribute_keys, frozenset({"key1"}))
self.assertIsNone(view._attribute_keys)

def test_stream_attribute_keys_exact_overlap_is_allowed(self):
view = self._get_view(
self._make_view_config(
stream_kwargs={
"attribute_keys": IncludeExclude(
included=["region"],
excluded=["region"],
)
}
)
)

self.assertEqual(
view._attribute_keys,
frozenset({"region"}),
)
self.assertEqual(
view._exclude_attribute_keys,
frozenset({"region"}),
)

def test_stream_attribute_key_pattern_intersection_is_allowed(
self,
):
view = self._get_view(
self._make_view_config(
stream_kwargs={
"attribute_keys": IncludeExclude(
included=["http.*"],
excluded=["*.status"],
)
}
)
)

self.assertEqual(
view._attribute_keys,
frozenset({"http.*"}),
)
self.assertEqual(
view._exclude_attribute_keys,
frozenset({"*.status"}),
)

def test_stream_empty_exclusion_list_is_allowed(self):
view = self._get_view(
self._make_view_config(
stream_kwargs={
"attribute_keys": IncludeExclude(
excluded=[],
)
}
)
)

self.assertIsNone(view._attribute_keys)
self.assertEqual(
view._exclude_attribute_keys,
frozenset(),
)

def test_stream_aggregation_drop(self):
view = self._get_view(self._make_view_config(stream_kwargs={"aggregation": AggregationConfig(drop={})}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
from opentelemetry.sdk.metrics._internal.instrument import _Instrument
from opentelemetry.sdk.metrics._internal.measurement import Measurement
from opentelemetry.sdk.metrics._internal.point import DataPointT
from opentelemetry.sdk.metrics._internal.view import View
from opentelemetry.sdk.metrics._internal.view import (
View,
_attribute_key_matches,
)
from opentelemetry.util.types import AnyValue, Attributes

_logger = getLogger(__name__)
Expand Down Expand Up @@ -141,8 +144,20 @@ def consume_measurement(self, measurement: Measurement, should_sample_exemplar:
# The user can still modify mutable attribute values (lists/dicts)
# leading to unexpected behavior, but deep copying is expensive.
attributes = dict(measurement.attributes)

if self._view._attribute_keys is not None:
attributes = {k: v for k, v in attributes.items() if k in self._view._attribute_keys}
attributes = {
key: value
for key, value in attributes.items()
if any(_attribute_key_matches(key, pattern) for pattern in self._view._attribute_keys)
}

if self._view._exclude_attribute_keys is not None:
attributes = {
key: value
for key, value in attributes.items()
if not any(_attribute_key_matches(key, pattern) for pattern in self._view._exclude_attribute_keys)
}

aggr_key = _hash_attributes(attributes)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0


from collections.abc import Callable
import re
from collections.abc import Callable, Iterable
from fnmatch import fnmatchcase
from logging import getLogger

Expand All @@ -24,6 +24,26 @@
_logger = getLogger(__name__)


def _normalize_attribute_keys(keys: Iterable[str] | None) -> frozenset[str] | None:
"""Normalize attribute-key iterables to immutable sets for internal use."""
if keys is None:
return None
if isinstance(keys, str):
return frozenset({keys})
return frozenset(keys)


def _attribute_key_matches(key: str, pattern: str) -> bool:
"""Match attribute keys using only ``*`` and ``?`` as wildcards."""
if pattern == key:
return True

expression = "".join(
".*" if char == "*" else "." if char == "?" else re.escape(char) for char in pattern
)
return re.fullmatch(expression, key) is not None


def _default_reservoir_factory(
aggregation_type: type[_Aggregation],
) -> ExemplarReservoirBuilder:
Expand Down Expand Up @@ -75,9 +95,13 @@ class View:
description of the metric stream. If `None`, the description of the instrument will
be used.

attribute_keys: This is a metric stream customizing attribute: this is
a set of attribute keys. If not `None` then only the measurement attributes that
are in ``attribute_keys`` will be used to identify the metric stream.
attribute_keys: This is a metric stream customizing attribute: a set-like
collection of attribute keys to keep. If not `None`, only the measurement
attributes whose keys are in ``attribute_keys`` are used to identify the
metric stream.

exclude_attribute_keys: This is a metric stream customizing attribute: a set-like
collection of attribute keys to drop after any include list is applied.

aggregation: This is a metric stream customizing attribute: the
aggregation instance to use when data is aggregated for the
Expand Down Expand Up @@ -112,10 +136,11 @@ def __init__(
meter_schema_url: str | None = None,
name: str | None = None,
description: str | None = None,
attribute_keys: set[str] | None = None,
attribute_keys: Iterable[str] | None = None,
aggregation: Aggregation | None = None,
exemplar_reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder] | None = None,
instrument_unit: str | None = None,
exclude_attribute_keys: Iterable[str] | None = None,
aggregation_cardinality_limit: int | None = None,
):
if aggregation_cardinality_limit is not None and (
Expand Down Expand Up @@ -150,8 +175,8 @@ def __init__(
raise Exception(f"View {name} declared with wildcard characters in instrument_name")

# _name, _description, _aggregation, _exemplar_reservoir_factory,
# _attribute_keys and _aggregation_cardinality_limit will be accessed
# when instantiating a _ViewInstrumentMatch.
# _attribute_keys, _exclude_attribute_keys and _aggregation_cardinality_limit
# will be accessed when instantiating a _ViewInstrumentMatch.
self._name = name
self._instrument_type = instrument_type
self._instrument_name = instrument_name
Expand All @@ -161,7 +186,8 @@ def __init__(
self._meter_schema_url = meter_schema_url

self._description = description
self._attribute_keys = attribute_keys
self._attribute_keys = _normalize_attribute_keys(attribute_keys)
self._exclude_attribute_keys = _normalize_attribute_keys(exclude_attribute_keys)
self._aggregation = aggregation or self._default_aggregation
self._exemplar_reservoir_factory = exemplar_reservoir_factory or _default_reservoir_factory
self._aggregation_cardinality_limit = aggregation_cardinality_limit
Expand Down
110 changes: 110 additions & 0 deletions opentelemetry-sdk/tests/metrics/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from unittest.mock import Mock

from opentelemetry.sdk.metrics import Counter, MeterProvider
from opentelemetry.sdk.metrics._internal.view import (
_attribute_key_matches,
)
from opentelemetry.sdk.metrics.view import View


Expand Down Expand Up @@ -164,6 +167,113 @@ def test_view_name(self):
self.assertEqual(view._name, "name")
self.assertEqual(view._instrument_name, "instrument_name_1")

def test_attribute_keys_and_exclude_attribute_keys_overlap(self):
test_cases = [
{
"name": "exact overlap",
"attribute_keys": ("method", "status_code"),
"exclude_attribute_keys": ("method", "user_id"),
},
{
"name": "intersecting wildcard patterns",
"attribute_keys": ("http.*",),
"exclude_attribute_keys": ("*.status",),
},
{
"name": "intersecting question mark patterns",
"attribute_keys": ("a?",),
"exclude_attribute_keys": ("?b",),
},
{
"name": "literal brackets do not overlap",
"attribute_keys": ("header0",),
"exclude_attribute_keys": ("header[0]",),
},
]

for case in test_cases:
with self.subTest(case=case["name"]):
view = View(
instrument_name="instrument_name",
attribute_keys=case["attribute_keys"],
exclude_attribute_keys=case[
"exclude_attribute_keys"
],
)

self.assertEqual(
view._attribute_keys,
frozenset(case["attribute_keys"]),
)
self.assertEqual(
view._exclude_attribute_keys,
frozenset(case["exclude_attribute_keys"]),
)

def test_attribute_key_matching_semantics(self):
test_cases = [
("exact match", "region", "region", True),
("star wildcard", "http.status", "http.*", True),
("question mark wildcard", "ab", "a?", True),
(
"question mark one character only",
"abc",
"a?",
False,
),
(
"literal bracket match",
"header[0]",
"header[0]",
True,
),
(
"brackets are not character class",
"header0",
"header[0]",
False,
),
(
"literal backslash match",
r"header\0",
r"header\0",
True,
),
(
"backslash is not discarded",
"header0",
r"header\0",
False,
),
(
"regex dot remains literal",
"metric.value",
"metric.value",
True,
),
(
"literal dot mismatch",
"metricXvalue",
"metric.value",
False,
),
]

for name, key, pattern, expected in test_cases:
with self.subTest(case=name):
self.assertIs(
_attribute_key_matches(key, pattern),
expected,
)

def test_attribute_keys_and_exclude_attribute_keys_disjoint(self):
view = View(
instrument_name="instrument_name",
attribute_keys=("method", "status_code"),
exclude_attribute_keys=("user_id",),
)

self.assertIsNotNone(view)
def test_view_name_without_instrument_name(self):
cases = [
("custom_name", {"instrument_type": Mock}),
Expand Down
Loading