Skip to content

Commit eda57f7

Browse files
authored
Apply validation of attributes to Resource, move attribute related logic to util package (#1834)
1 parent ef7a415 commit eda57f7

File tree

7 files changed

+229
-99
lines changed

7 files changed

+229
-99
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4444
([#1818](https://github.com/open-telemetry/opentelemetry-python/pull/1818))
4545
- Update transient errors retry timeout and retryable status codes
4646
([#1842](https://github.com/open-telemetry/opentelemetry-python/pull/1842))
47+
- Apply validation of attributes to `Resource`, move attribute related logic to separate package.
48+
([#1834](https://github.com/open-telemetry/opentelemetry-python/pull/1834))
4749
- Fix start span behavior when excess links and attributes are included
4850
([#1856](https://github.com/open-telemetry/opentelemetry-python/pull/1856))
4951

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
# type: ignore
15+
16+
import logging
17+
from types import MappingProxyType
18+
from typing import MutableSequence, Sequence
19+
20+
from opentelemetry.util import types
21+
22+
_VALID_ATTR_VALUE_TYPES = (bool, str, int, float)
23+
24+
25+
_logger = logging.getLogger(__name__)
26+
27+
28+
def _is_valid_attribute_value(value: types.AttributeValue) -> bool:
29+
"""Checks if attribute value is valid.
30+
31+
An attribute value is valid if it is either:
32+
- A primitive type: string, boolean, double precision floating
33+
point (IEEE 754-1985) or integer.
34+
- An array of primitive type values. The array MUST be homogeneous,
35+
i.e. it MUST NOT contain values of different types.
36+
"""
37+
38+
if isinstance(value, Sequence):
39+
if len(value) == 0:
40+
return True
41+
42+
sequence_first_valid_type = None
43+
for element in value:
44+
if element is None:
45+
continue
46+
element_type = type(element)
47+
if element_type not in _VALID_ATTR_VALUE_TYPES:
48+
_logger.warning(
49+
"Invalid type %s in attribute value sequence. Expected one of "
50+
"%s or None",
51+
element_type.__name__,
52+
[
53+
valid_type.__name__
54+
for valid_type in _VALID_ATTR_VALUE_TYPES
55+
],
56+
)
57+
return False
58+
# The type of the sequence must be homogeneous. The first non-None
59+
# element determines the type of the sequence
60+
if sequence_first_valid_type is None:
61+
sequence_first_valid_type = element_type
62+
elif not isinstance(element, sequence_first_valid_type):
63+
_logger.warning(
64+
"Mixed types %s and %s in attribute value sequence",
65+
sequence_first_valid_type.__name__,
66+
type(element).__name__,
67+
)
68+
return False
69+
70+
elif not isinstance(value, _VALID_ATTR_VALUE_TYPES):
71+
_logger.warning(
72+
"Invalid type %s for attribute value. Expected one of %s or a "
73+
"sequence of those types",
74+
type(value).__name__,
75+
[valid_type.__name__ for valid_type in _VALID_ATTR_VALUE_TYPES],
76+
)
77+
return False
78+
return True
79+
80+
81+
def _filter_attributes(attributes: types.Attributes) -> None:
82+
"""Applies attribute validation rules and drops (key, value) pairs
83+
that doesn't adhere to attributes specification.
84+
85+
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/common/common.md#attributes.
86+
"""
87+
if attributes:
88+
for attr_key, attr_value in list(attributes.items()):
89+
if not attr_key:
90+
_logger.warning("invalid key `%s` (empty or null)", attr_key)
91+
attributes.pop(attr_key)
92+
continue
93+
94+
if _is_valid_attribute_value(attr_value):
95+
if isinstance(attr_value, MutableSequence):
96+
attributes[attr_key] = tuple(attr_value)
97+
if isinstance(attr_value, bytes):
98+
try:
99+
attributes[attr_key] = attr_value.decode()
100+
except ValueError:
101+
attributes.pop(attr_key)
102+
_logger.warning("Byte attribute could not be decoded.")
103+
else:
104+
attributes.pop(attr_key)
105+
106+
107+
def _create_immutable_attributes(
108+
attributes: types.Attributes,
109+
) -> types.Attributes:
110+
return MappingProxyType(attributes.copy() if attributes else {})
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# type: ignore
16+
17+
import unittest
18+
19+
from opentelemetry.attributes import (
20+
_create_immutable_attributes,
21+
_filter_attributes,
22+
_is_valid_attribute_value,
23+
)
24+
25+
26+
class TestAttributes(unittest.TestCase):
27+
def test_is_valid_attribute_value(self):
28+
self.assertFalse(_is_valid_attribute_value([1, 2, 3.4, "ss", 4]))
29+
self.assertFalse(_is_valid_attribute_value([dict(), 1, 2, 3.4, 4]))
30+
self.assertFalse(_is_valid_attribute_value(["sw", "lf", 3.4, "ss"]))
31+
self.assertFalse(_is_valid_attribute_value([1, 2, 3.4, 5]))
32+
self.assertFalse(_is_valid_attribute_value(dict()))
33+
self.assertTrue(_is_valid_attribute_value(True))
34+
self.assertTrue(_is_valid_attribute_value("hi"))
35+
self.assertTrue(_is_valid_attribute_value(3.4))
36+
self.assertTrue(_is_valid_attribute_value(15))
37+
self.assertTrue(_is_valid_attribute_value([1, 2, 3, 5]))
38+
self.assertTrue(_is_valid_attribute_value([1.2, 2.3, 3.4, 4.5]))
39+
self.assertTrue(_is_valid_attribute_value([True, False]))
40+
self.assertTrue(_is_valid_attribute_value(["ss", "dw", "fw"]))
41+
self.assertTrue(_is_valid_attribute_value([]))
42+
# None in sequences are valid
43+
self.assertTrue(_is_valid_attribute_value(["A", None, None]))
44+
self.assertTrue(_is_valid_attribute_value(["A", None, None, "B"]))
45+
self.assertTrue(_is_valid_attribute_value([None, None]))
46+
self.assertFalse(_is_valid_attribute_value(["A", None, 1]))
47+
self.assertFalse(_is_valid_attribute_value([None, "A", None, 1]))
48+
49+
def test_filter_attributes(self):
50+
attrs_with_invalid_keys = {
51+
"": "empty-key",
52+
None: "None-value",
53+
"attr-key": "attr-value",
54+
}
55+
_filter_attributes(attrs_with_invalid_keys)
56+
self.assertTrue(len(attrs_with_invalid_keys), 1)
57+
self.assertEqual(attrs_with_invalid_keys, {"attr-key": "attr-value"})
58+
59+
attrs_with_invalid_values = {
60+
"nonhomogeneous": [1, 2, 3.4, "ss", 4],
61+
"nonprimitive": dict(),
62+
"mixed": [1, 2.4, "st", dict()],
63+
"validkey1": "validvalue1",
64+
"intkey": 5,
65+
"floatkey": 3.14,
66+
"boolkey": True,
67+
"valid-byte-string": b"hello-otel",
68+
}
69+
_filter_attributes(attrs_with_invalid_values)
70+
self.assertEqual(len(attrs_with_invalid_values), 5)
71+
self.assertEqual(
72+
attrs_with_invalid_values,
73+
{
74+
"validkey1": "validvalue1",
75+
"intkey": 5,
76+
"floatkey": 3.14,
77+
"boolkey": True,
78+
"valid-byte-string": "hello-otel",
79+
},
80+
)
81+
82+
def test_create_immutable_attributes(self):
83+
attrs = {"key": "value", "pi": 3.14}
84+
immutable = _create_immutable_attributes(attrs)
85+
# TypeError: 'mappingproxy' object does not support item assignment
86+
with self.assertRaises(TypeError):
87+
immutable["pi"] = 1.34

opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464

6565
import pkg_resources
6666

67+
from opentelemetry.attributes import _filter_attributes
6768
from opentelemetry.sdk.environment_variables import (
6869
OTEL_RESOURCE_ATTRIBUTES,
6970
OTEL_SERVICE_NAME,
@@ -141,6 +142,7 @@ class Resource:
141142
"""A Resource is an immutable representation of the entity producing telemetry as Attributes."""
142143

143144
def __init__(self, attributes: Attributes):
145+
_filter_attributes(attributes)
144146
self._attributes = attributes.copy()
145147

146148
@staticmethod

opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py

Lines changed: 8 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939

4040
from opentelemetry import context as context_api
4141
from opentelemetry import trace as trace_api
42+
from opentelemetry.attributes import (
43+
_create_immutable_attributes,
44+
_filter_attributes,
45+
_is_valid_attribute_value,
46+
)
4247
from opentelemetry.sdk import util
4348
from opentelemetry.sdk.environment_variables import (
4449
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT,
@@ -64,7 +69,6 @@
6469

6570
_SPAN_EVENT_COUNT_LIMIT = int(environ.get(OTEL_SPAN_EVENT_COUNT_LIMIT, 128))
6671
_SPAN_LINK_COUNT_LIMIT = int(environ.get(OTEL_SPAN_LINK_COUNT_LIMIT, 128))
67-
_VALID_ATTR_VALUE_TYPES = (bool, str, int, float)
6872
# pylint: disable=protected-access
6973
_TRACE_SAMPLER = sampling._get_from_env_or_default()
7074

@@ -315,72 +319,6 @@ def attributes(self) -> types.Attributes:
315319
return self._attributes
316320

317321

318-
def _is_valid_attribute_value(value: types.AttributeValue) -> bool:
319-
"""Checks if attribute value is valid.
320-
321-
An attribute value is valid if it is one of the valid types.
322-
If the value is a sequence, it is only valid if all items in the sequence:
323-
- are of the same valid type or None
324-
- are not a sequence
325-
"""
326-
327-
if isinstance(value, Sequence):
328-
if len(value) == 0:
329-
return True
330-
331-
sequence_first_valid_type = None
332-
for element in value:
333-
if element is None:
334-
continue
335-
element_type = type(element)
336-
if element_type not in _VALID_ATTR_VALUE_TYPES:
337-
logger.warning(
338-
"Invalid type %s in attribute value sequence. Expected one of "
339-
"%s or None",
340-
element_type.__name__,
341-
[
342-
valid_type.__name__
343-
for valid_type in _VALID_ATTR_VALUE_TYPES
344-
],
345-
)
346-
return False
347-
# The type of the sequence must be homogeneous. The first non-None
348-
# element determines the type of the sequence
349-
if sequence_first_valid_type is None:
350-
sequence_first_valid_type = element_type
351-
elif not isinstance(element, sequence_first_valid_type):
352-
logger.warning(
353-
"Mixed types %s and %s in attribute value sequence",
354-
sequence_first_valid_type.__name__,
355-
type(element).__name__,
356-
)
357-
return False
358-
359-
elif not isinstance(value, _VALID_ATTR_VALUE_TYPES):
360-
logger.warning(
361-
"Invalid type %s for attribute value. Expected one of %s or a "
362-
"sequence of those types",
363-
type(value).__name__,
364-
[valid_type.__name__ for valid_type in _VALID_ATTR_VALUE_TYPES],
365-
)
366-
return False
367-
return True
368-
369-
370-
def _filter_attribute_values(attributes: types.Attributes):
371-
if attributes:
372-
for attr_key, attr_value in list(attributes.items()):
373-
if _is_valid_attribute_value(attr_value):
374-
if isinstance(attr_value, MutableSequence):
375-
attributes[attr_key] = tuple(attr_value)
376-
else:
377-
attributes.pop(attr_key)
378-
379-
380-
def _create_immutable_attributes(attributes):
381-
return MappingProxyType(attributes.copy() if attributes else {})
382-
383-
384322
def _check_span_ended(func):
385323
def wrapper(self, *args, **kwargs):
386324
already_ended = False
@@ -623,7 +561,7 @@ def __init__(
623561
self._span_processor = span_processor
624562
self._lock = threading.Lock()
625563

626-
_filter_attribute_values(attributes)
564+
_filter_attributes(attributes)
627565
if not attributes:
628566
self._attributes = self._new_attributes()
629567
else:
@@ -634,7 +572,7 @@ def __init__(
634572
self._events = self._new_events()
635573
if events:
636574
for event in events:
637-
_filter_attribute_values(event.attributes)
575+
_filter_attributes(event.attributes)
638576
# pylint: disable=protected-access
639577
event._attributes = _create_immutable_attributes(
640578
event.attributes
@@ -709,7 +647,7 @@ def add_event(
709647
attributes: types.Attributes = None,
710648
timestamp: Optional[int] = None,
711649
) -> None:
712-
_filter_attribute_values(attributes)
650+
_filter_attributes(attributes)
713651
attributes = _create_immutable_attributes(attributes)
714652
self._add_event(
715653
Event(

opentelemetry-sdk/tests/resources/test_resources.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import os
1818
import unittest
19+
import uuid
1920
from unittest import mock
2021

2122
from opentelemetry.sdk import resources
@@ -138,6 +139,25 @@ def test_service_name_using_process_name(self):
138139
"unknown_service:test",
139140
)
140141

142+
def test_invalid_resource_attribute_values(self):
143+
resource = resources.Resource(
144+
{
145+
resources.SERVICE_NAME: "test",
146+
"non-primitive-data-type": dict(),
147+
"invalid-byte-type-attribute": b"\xd8\xe1\xb7\xeb\xa8\xe5 \xd2\xb7\xe1",
148+
"": "empty-key-value",
149+
None: "null-key-value",
150+
"another-non-primitive": uuid.uuid4(),
151+
}
152+
)
153+
self.assertEqual(
154+
resource.attributes,
155+
{
156+
resources.SERVICE_NAME: "test",
157+
},
158+
)
159+
self.assertEqual(len(resource.attributes), 1)
160+
141161
def test_aggregated_resources_no_detectors(self):
142162
aggregated_resources = resources.get_aggregated_resources([])
143163
self.assertEqual(aggregated_resources, resources.Resource.get_empty())

0 commit comments

Comments
 (0)