diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a84910d08..b38676ca564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#1823](https://github.com/open-telemetry/opentelemetry-python/pull/1823)) - Added support for OTEL_SERVICE_NAME. ([#1829](https://github.com/open-telemetry/opentelemetry-python/pull/1829)) +- Lazily read/configure limits and allow limits to be unset. + ([#1839](https://github.com/open-telemetry/opentelemetry-python/pull/1839)) ### Changed - Fixed OTLP gRPC exporter silently failing if scheme is not specified in endpoint. @@ -46,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#1818](https://github.com/open-telemetry/opentelemetry-python/pull/1818)) - Update transient errors retry timeout and retryable status codes ([#1842](https://github.com/open-telemetry/opentelemetry-python/pull/1842)) +- Apply validation of attributes to `Resource`, move attribute related logic to separate package. + ([#1834](https://github.com/open-telemetry/opentelemetry-python/pull/1834)) - Fix start span behavior when excess links and attributes are included ([#1856](https://github.com/open-telemetry/opentelemetry-python/pull/1856)) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py new file mode 100644 index 00000000000..6875f56631f --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -0,0 +1,110 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# type: ignore + +import logging +from types import MappingProxyType +from typing import MutableSequence, Sequence + +from opentelemetry.util import types + +_VALID_ATTR_VALUE_TYPES = (bool, str, int, float) + + +_logger = logging.getLogger(__name__) + + +def _is_valid_attribute_value(value: types.AttributeValue) -> bool: + """Checks if attribute value is valid. + + An attribute value is valid if it is either: + - A primitive type: string, boolean, double precision floating + point (IEEE 754-1985) or integer. + - An array of primitive type values. The array MUST be homogeneous, + i.e. it MUST NOT contain values of different types. + """ + + if isinstance(value, Sequence): + if len(value) == 0: + return True + + sequence_first_valid_type = None + for element in value: + if element is None: + continue + element_type = type(element) + if element_type not in _VALID_ATTR_VALUE_TYPES: + _logger.warning( + "Invalid type %s in attribute value sequence. Expected one of " + "%s or None", + element_type.__name__, + [ + valid_type.__name__ + for valid_type in _VALID_ATTR_VALUE_TYPES + ], + ) + return False + # The type of the sequence must be homogeneous. The first non-None + # element determines the type of the sequence + if sequence_first_valid_type is None: + sequence_first_valid_type = element_type + elif not isinstance(element, sequence_first_valid_type): + _logger.warning( + "Mixed types %s and %s in attribute value sequence", + sequence_first_valid_type.__name__, + type(element).__name__, + ) + return False + + elif not isinstance(value, _VALID_ATTR_VALUE_TYPES): + _logger.warning( + "Invalid type %s for attribute value. Expected one of %s or a " + "sequence of those types", + type(value).__name__, + [valid_type.__name__ for valid_type in _VALID_ATTR_VALUE_TYPES], + ) + return False + return True + + +def _filter_attributes(attributes: types.Attributes) -> None: + """Applies attribute validation rules and drops (key, value) pairs + that doesn't adhere to attributes specification. + + https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/common/common.md#attributes. + """ + if attributes: + for attr_key, attr_value in list(attributes.items()): + if not attr_key: + _logger.warning("invalid key `%s` (empty or null)", attr_key) + attributes.pop(attr_key) + continue + + if _is_valid_attribute_value(attr_value): + if isinstance(attr_value, MutableSequence): + attributes[attr_key] = tuple(attr_value) + if isinstance(attr_value, bytes): + try: + attributes[attr_key] = attr_value.decode() + except ValueError: + attributes.pop(attr_key) + _logger.warning("Byte attribute could not be decoded.") + else: + attributes.pop(attr_key) + + +def _create_immutable_attributes( + attributes: types.Attributes, +) -> types.Attributes: + return MappingProxyType(attributes.copy() if attributes else {}) diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py new file mode 100644 index 00000000000..2a391f78af7 --- /dev/null +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -0,0 +1,87 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# type: ignore + +import unittest + +from opentelemetry.attributes import ( + _create_immutable_attributes, + _filter_attributes, + _is_valid_attribute_value, +) + + +class TestAttributes(unittest.TestCase): + def test_is_valid_attribute_value(self): + self.assertFalse(_is_valid_attribute_value([1, 2, 3.4, "ss", 4])) + self.assertFalse(_is_valid_attribute_value([dict(), 1, 2, 3.4, 4])) + self.assertFalse(_is_valid_attribute_value(["sw", "lf", 3.4, "ss"])) + self.assertFalse(_is_valid_attribute_value([1, 2, 3.4, 5])) + self.assertFalse(_is_valid_attribute_value(dict())) + self.assertTrue(_is_valid_attribute_value(True)) + self.assertTrue(_is_valid_attribute_value("hi")) + self.assertTrue(_is_valid_attribute_value(3.4)) + self.assertTrue(_is_valid_attribute_value(15)) + self.assertTrue(_is_valid_attribute_value([1, 2, 3, 5])) + self.assertTrue(_is_valid_attribute_value([1.2, 2.3, 3.4, 4.5])) + self.assertTrue(_is_valid_attribute_value([True, False])) + self.assertTrue(_is_valid_attribute_value(["ss", "dw", "fw"])) + self.assertTrue(_is_valid_attribute_value([])) + # None in sequences are valid + self.assertTrue(_is_valid_attribute_value(["A", None, None])) + self.assertTrue(_is_valid_attribute_value(["A", None, None, "B"])) + self.assertTrue(_is_valid_attribute_value([None, None])) + self.assertFalse(_is_valid_attribute_value(["A", None, 1])) + self.assertFalse(_is_valid_attribute_value([None, "A", None, 1])) + + def test_filter_attributes(self): + attrs_with_invalid_keys = { + "": "empty-key", + None: "None-value", + "attr-key": "attr-value", + } + _filter_attributes(attrs_with_invalid_keys) + self.assertTrue(len(attrs_with_invalid_keys), 1) + self.assertEqual(attrs_with_invalid_keys, {"attr-key": "attr-value"}) + + attrs_with_invalid_values = { + "nonhomogeneous": [1, 2, 3.4, "ss", 4], + "nonprimitive": dict(), + "mixed": [1, 2.4, "st", dict()], + "validkey1": "validvalue1", + "intkey": 5, + "floatkey": 3.14, + "boolkey": True, + "valid-byte-string": b"hello-otel", + } + _filter_attributes(attrs_with_invalid_values) + self.assertEqual(len(attrs_with_invalid_values), 5) + self.assertEqual( + attrs_with_invalid_values, + { + "validkey1": "validvalue1", + "intkey": 5, + "floatkey": 3.14, + "boolkey": True, + "valid-byte-string": "hello-otel", + }, + ) + + def test_create_immutable_attributes(self): + attrs = {"key": "value", "pi": 3.14} + immutable = _create_immutable_attributes(attrs) + # TypeError: 'mappingproxy' object does not support item assignment + with self.assertRaises(TypeError): + immutable["pi"] = 1.34 diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index dec35b6c17d..5ecdd661899 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -64,6 +64,7 @@ import pkg_resources +from opentelemetry.attributes import _filter_attributes from opentelemetry.sdk.environment_variables import ( OTEL_RESOURCE_ATTRIBUTES, OTEL_SERVICE_NAME, @@ -141,6 +142,7 @@ class Resource: """A Resource is an immutable representation of the entity producing telemetry as Attributes.""" def __init__(self, attributes: Attributes, schema_url: str): + _filter_attributes(attributes) self._attributes = attributes.copy() self._schema_url = schema_url diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index ad1d8284cdf..c202f5cbea1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -39,6 +39,11 @@ from opentelemetry import context as context_api from opentelemetry import trace as trace_api +from opentelemetry.attributes import ( + _create_immutable_attributes, + _filter_attributes, + _is_valid_attribute_value, +) from opentelemetry.sdk import util from opentelemetry.sdk.environment_variables import ( OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, @@ -51,20 +56,16 @@ from opentelemetry.sdk.util import BoundedDict, BoundedList from opentelemetry.sdk.util.instrumentation import InstrumentationInfo from opentelemetry.trace import SpanContext -from opentelemetry.trace.propagation import SPAN_KEY from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util import types from opentelemetry.util._time import _time_ns logger = logging.getLogger(__name__) -SPAN_ATTRIBUTE_COUNT_LIMIT = int( - environ.get(OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, 128) -) +_DEFAULT_SPAN_EVENTS_LIMIT = 128 +_DEFAULT_SPAN_LINKS_LIMIT = 128 +_DEFAULT_SPAN_ATTRIBUTES_LIMIT = 128 -_SPAN_EVENT_COUNT_LIMIT = int(environ.get(OTEL_SPAN_EVENT_COUNT_LIMIT, 128)) -_SPAN_LINK_COUNT_LIMIT = int(environ.get(OTEL_SPAN_LINK_COUNT_LIMIT, 128)) -_VALID_ATTR_VALUE_TYPES = (bool, str, int, float) # pylint: disable=protected-access _TRACE_SAMPLER = sampling._get_from_env_or_default() @@ -315,72 +316,6 @@ def attributes(self) -> types.Attributes: return self._attributes -def _is_valid_attribute_value(value: types.AttributeValue) -> bool: - """Checks if attribute value is valid. - - An attribute value is valid if it is one of the valid types. - If the value is a sequence, it is only valid if all items in the sequence: - - are of the same valid type or None - - are not a sequence - """ - - if isinstance(value, Sequence): - if len(value) == 0: - return True - - sequence_first_valid_type = None - for element in value: - if element is None: - continue - element_type = type(element) - if element_type not in _VALID_ATTR_VALUE_TYPES: - logger.warning( - "Invalid type %s in attribute value sequence. Expected one of " - "%s or None", - element_type.__name__, - [ - valid_type.__name__ - for valid_type in _VALID_ATTR_VALUE_TYPES - ], - ) - return False - # The type of the sequence must be homogeneous. The first non-None - # element determines the type of the sequence - if sequence_first_valid_type is None: - sequence_first_valid_type = element_type - elif not isinstance(element, sequence_first_valid_type): - logger.warning( - "Mixed types %s and %s in attribute value sequence", - sequence_first_valid_type.__name__, - type(element).__name__, - ) - return False - - elif not isinstance(value, _VALID_ATTR_VALUE_TYPES): - logger.warning( - "Invalid type %s for attribute value. Expected one of %s or a " - "sequence of those types", - type(value).__name__, - [valid_type.__name__ for valid_type in _VALID_ATTR_VALUE_TYPES], - ) - return False - return True - - -def _filter_attribute_values(attributes: types.Attributes): - if attributes: - for attr_key, attr_value in list(attributes.items()): - if _is_valid_attribute_value(attr_value): - if isinstance(attr_value, MutableSequence): - attributes[attr_key] = tuple(attr_value) - else: - attributes.pop(attr_key) - - -def _create_immutable_attributes(attributes): - return MappingProxyType(attributes.copy() if attributes else {}) - - def _check_span_ended(func): def wrapper(self, *args, **kwargs): already_ended = False @@ -564,6 +499,87 @@ def _format_links(links): return f_links +class _Limits: + """The limits that should be enforce on recorded data such as events, links, attributes etc. + + This class does not enforce any limits itself. It only provides an a way read limits from env, + default values and in future from user provided arguments. + + All limit must be either a non-negative integer or ``None``. + Setting a limit to ``None`` will not set any limits for that field/type. + + Args: + max_events: Maximum number of events that can be added to a Span. + max_links: Maximum number of links that can be added to a Span. + max_attributes: Maximum number of attributes that can be added to a Span. + """ + + UNSET = -1 + + max_attributes: int + max_events: int + max_links: int + + def __init__( + self, + max_attributes: Optional[int] = None, + max_events: Optional[int] = None, + max_links: Optional[int] = None, + ): + self.max_attributes = self._from_env_if_absent( + max_attributes, + OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_SPAN_ATTRIBUTES_LIMIT, + ) + self.max_events = self._from_env_if_absent( + max_events, OTEL_SPAN_EVENT_COUNT_LIMIT, _DEFAULT_SPAN_EVENTS_LIMIT + ) + self.max_links = self._from_env_if_absent( + max_links, OTEL_SPAN_LINK_COUNT_LIMIT, _DEFAULT_SPAN_LINKS_LIMIT + ) + + def __repr__(self): + return "max_attributes={}, max_events={}, max_links={}".format( + self.max_attributes, self.max_events, self.max_links + ) + + @classmethod + def _from_env_if_absent( + cls, value: Optional[int], env_var: str, default: Optional[int] + ) -> Optional[int]: + if value is cls.UNSET: + return None + + err_msg = "{0} must be a non-negative integer but got {}" + + if value is None: + str_value = environ.get(env_var, "").strip().lower() + if not str_value: + return default + if str_value == "unset": + return None + + try: + value = int(str_value) + except ValueError: + raise ValueError(err_msg.format(env_var, str_value)) + + if value < 0: + raise ValueError(err_msg.format(env_var, value)) + return value + + +_UnsetLimits = _Limits( + max_attributes=_Limits.UNSET, + max_events=_Limits.UNSET, + max_links=_Limits.UNSET, +) + +SPAN_ATTRIBUTE_COUNT_LIMIT = _Limits._from_env_if_absent( + None, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, _DEFAULT_SPAN_ATTRIBUTES_LIMIT +) + + class Span(trace_api.Span, ReadableSpan): """See `opentelemetry.trace.Span`. @@ -623,18 +639,18 @@ def __init__( self._span_processor = span_processor self._lock = threading.Lock() - _filter_attribute_values(attributes) + _filter_attributes(attributes) if not attributes: self._attributes = self._new_attributes() else: self._attributes = BoundedDict.from_map( - SPAN_ATTRIBUTE_COUNT_LIMIT, attributes + self._limits.max_attributes, attributes ) self._events = self._new_events() if events: for event in events: - _filter_attribute_values(event.attributes) + _filter_attributes(event.attributes) # pylint: disable=protected-access event._attributes = _create_immutable_attributes( event.attributes @@ -644,24 +660,21 @@ def __init__( if links is None: self._links = self._new_links() else: - self._links = BoundedList.from_seq(_SPAN_LINK_COUNT_LIMIT, links) + self._links = BoundedList.from_seq(self._limits.max_links, links) def __repr__(self): return '{}(name="{}", context={})'.format( type(self).__name__, self._name, self._context ) - @staticmethod - def _new_attributes(): - return BoundedDict(SPAN_ATTRIBUTE_COUNT_LIMIT) + def _new_attributes(self): + return BoundedDict(self._limits.max_attributes) - @staticmethod - def _new_events(): - return BoundedList(_SPAN_EVENT_COUNT_LIMIT) + def _new_events(self): + return BoundedList(self._limits.max_events) - @staticmethod - def _new_links(): - return BoundedList(_SPAN_LINK_COUNT_LIMIT) + def _new_links(self): + return BoundedList(self._limits.max_links) def get_span_context(self): return self._context @@ -709,7 +722,7 @@ def add_event( attributes: types.Attributes = None, timestamp: Optional[int] = None, ) -> None: - _filter_attribute_values(attributes) + _filter_attributes(attributes) attributes = _create_immutable_attributes(attributes) self._add_event( Event( @@ -834,6 +847,10 @@ class _Span(Span): by other mechanisms than through the `Tracer`. """ + def __init__(self, *args, limits=_UnsetLimits, **kwargs): + self._limits = limits + super().__init__(*args, **kwargs) + class Tracer(trace_api.Tracer): """See `opentelemetry.trace.Tracer`.""" @@ -853,6 +870,7 @@ def __init__( self.span_processor = span_processor self.id_generator = id_generator self.instrumentation_info = instrumentation_info + self._limits = None @contextmanager def start_as_current_span( @@ -954,6 +972,7 @@ def start_span( # pylint: disable=too-many-locals instrumentation_info=self.instrumentation_info, record_exception=record_exception, set_status_on_exception=set_status_on_exception, + limits=self._limits, ) span.start(start_time=start_time, parent_context=context) else: @@ -983,6 +1002,7 @@ def __init__( self.id_generator = id_generator self._resource = resource self.sampler = sampler + self._limits = _Limits() self._atexit_handler = None if shutdown_on_exit: self._atexit_handler = atexit.register(self.shutdown) @@ -999,7 +1019,7 @@ def get_tracer( if not instrumenting_module_name: # Reject empty strings too. instrumenting_module_name = "" logger.error("get_tracer called with missing module name.") - return Tracer( + tracer = Tracer( self.sampler, self.resource, self._active_span_processor, @@ -1008,6 +1028,8 @@ def get_tracer( instrumenting_module_name, instrumenting_library_version ), ) + tracer._limits = self._limits + return tracer def add_span_processor(self, span_processor: SpanProcessor) -> None: """Registers a new :class:`SpanProcessor` for this `TracerProvider`. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index 981368049fb..746f100277d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -16,6 +16,7 @@ import threading from collections import OrderedDict, deque from collections.abc import MutableMapping, Sequence +from typing import Optional def ns_to_iso_str(nanoseconds): @@ -45,7 +46,7 @@ class BoundedList(Sequence): not enough room. """ - def __init__(self, maxlen): + def __init__(self, maxlen: Optional[int]): self.dropped = 0 self._dq = deque(maxlen=maxlen) # type: deque self._lock = threading.Lock() @@ -67,15 +68,19 @@ def __iter__(self): def append(self, item): with self._lock: - if len(self._dq) == self._dq.maxlen: + if ( + self._dq.maxlen is not None + and len(self._dq) == self._dq.maxlen + ): self.dropped += 1 self._dq.append(item) def extend(self, seq): with self._lock: - to_drop = len(seq) + len(self._dq) - self._dq.maxlen - if to_drop > 0: - self.dropped += to_drop + if self._dq.maxlen is not None: + to_drop = len(seq) + len(self._dq) - self._dq.maxlen + if to_drop > 0: + self.dropped += to_drop self._dq.extend(seq) @classmethod @@ -93,11 +98,12 @@ class BoundedDict(MutableMapping): added. """ - def __init__(self, maxlen): - if not isinstance(maxlen, int): - raise ValueError - if maxlen < 0: - raise ValueError + def __init__(self, maxlen: Optional[int]): + if maxlen is not None: + if not isinstance(maxlen, int): + raise ValueError + if maxlen < 0: + raise ValueError self.maxlen = maxlen self.dropped = 0 self._dict = OrderedDict() # type: OrderedDict @@ -113,13 +119,13 @@ def __getitem__(self, key): def __setitem__(self, key, value): with self._lock: - if self.maxlen == 0: + if self.maxlen is not None and self.maxlen == 0: self.dropped += 1 return if key in self._dict: del self._dict[key] - elif len(self._dict) == self.maxlen: + elif self.maxlen is not None and len(self._dict) == self.maxlen: del self._dict[next(iter(self._dict.keys()))] self.dropped += 1 self._dict[key] = value diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 816b594d75c..1c14b76debd 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -16,6 +16,7 @@ import os import unittest +import uuid from logging import ERROR from unittest import mock @@ -201,6 +202,25 @@ def test_service_name_using_process_name(self): "unknown_service:test", ) + def test_invalid_resource_attribute_values(self): + resource = resources.Resource( + { + resources.SERVICE_NAME: "test", + "non-primitive-data-type": dict(), + "invalid-byte-type-attribute": b"\xd8\xe1\xb7\xeb\xa8\xe5 \xd2\xb7\xe1", + "": "empty-key-value", + None: "null-key-value", + "another-non-primitive": uuid.uuid4(), + } + ) + self.assertEqual( + resource.attributes, + { + resources.SERVICE_NAME: "test", + }, + ) + self.assertEqual(len(resource.attributes), 1) + def test_aggregated_resources_no_detectors(self): aggregated_resources = resources.get_aggregated_resources([]) self.assertEqual(aggregated_resources, resources.Resource.get_empty()) diff --git a/opentelemetry-sdk/tests/test_util.py b/opentelemetry-sdk/tests/test_util.py index e003e70789c..f90576afe70 100644 --- a/opentelemetry-sdk/tests/test_util.py +++ b/opentelemetry-sdk/tests/test_util.py @@ -135,6 +135,14 @@ def test_extend_drop(self): self.assertEqual(len(blist), list_len) self.assertEqual(blist.dropped, len(other_list)) + def test_no_limit(self): + blist = BoundedList(maxlen=None) + for num in range(100): + blist.append(num) + + for num in range(100): + self.assertEqual(blist[num], num) + class TestBoundedDict(unittest.TestCase): base = collections.OrderedDict( @@ -214,3 +222,11 @@ def test_bounded_dict(self): with self.assertRaises(KeyError): _ = bdict["new-name"] + + def test_no_limit_code(self): + bdict = BoundedDict(maxlen=None) + for num in range(100): + bdict[num] = num + + for num in range(100): + self.assertEqual(bdict[num], num) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 236c604e879..b9925409917 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -18,6 +18,7 @@ import unittest from importlib import reload from logging import ERROR, WARNING +from random import randint from typing import Optional from unittest import mock @@ -538,7 +539,7 @@ def test_disallow_direct_span_creation(self): def test_surplus_span_links(self): # pylint: disable=protected-access - max_links = trace._SPAN_LINK_COUNT_LIMIT + max_links = trace._Limits().max_links links = [ trace_api.Link(trace_api.SpanContext(0x1, idx, is_remote=False)) for idx in range(0, 16 + max_links) @@ -548,7 +549,8 @@ def test_surplus_span_links(self): self.assertEqual(len(root.links), max_links) def test_surplus_span_attributes(self): - max_attrs = trace.SPAN_ATTRIBUTE_COUNT_LIMIT + # pylint: disable=protected-access + max_attrs = trace._Limits().max_attributes attributes = {str(idx): idx for idx in range(0, 16 + max_attrs)} tracer = new_tracer() with tracer.start_as_current_span( @@ -671,35 +673,6 @@ def test_byte_type_attribute_value(self): isinstance(root.attributes["valid-byte-type-attribute"], str) ) - def test_check_attribute_helper(self): - # pylint: disable=protected-access - self.assertFalse(trace._is_valid_attribute_value([1, 2, 3.4, "ss", 4])) - self.assertFalse( - trace._is_valid_attribute_value([dict(), 1, 2, 3.4, 4]) - ) - self.assertFalse( - trace._is_valid_attribute_value(["sw", "lf", 3.4, "ss"]) - ) - self.assertFalse(trace._is_valid_attribute_value([1, 2, 3.4, 5])) - self.assertTrue(trace._is_valid_attribute_value([1, 2, 3, 5])) - self.assertTrue(trace._is_valid_attribute_value([1.2, 2.3, 3.4, 4.5])) - self.assertTrue(trace._is_valid_attribute_value([True, False])) - self.assertTrue(trace._is_valid_attribute_value(["ss", "dw", "fw"])) - self.assertTrue(trace._is_valid_attribute_value([])) - self.assertFalse(trace._is_valid_attribute_value(dict())) - self.assertTrue(trace._is_valid_attribute_value(True)) - self.assertTrue(trace._is_valid_attribute_value("hi")) - self.assertTrue(trace._is_valid_attribute_value(3.4)) - self.assertTrue(trace._is_valid_attribute_value(15)) - # None in sequences are valid - self.assertTrue(trace._is_valid_attribute_value(["A", None, None])) - self.assertTrue( - trace._is_valid_attribute_value(["A", None, None, "B"]) - ) - self.assertTrue(trace._is_valid_attribute_value([None, None])) - self.assertFalse(trace._is_valid_attribute_value(["A", None, 1])) - self.assertFalse(trace._is_valid_attribute_value([None, "A", None, 1])) - def test_sampling_attributes(self): sampling_attributes = { "sampler-attr": "sample-val", @@ -1299,6 +1272,50 @@ def test_attributes_to_json(self): class TestSpanLimits(unittest.TestCase): + # pylint: disable=protected-access + + def test_limits_defaults(self): + limits = trace._Limits() + self.assertEqual( + limits.max_attributes, trace._DEFAULT_SPAN_ATTRIBUTES_LIMIT + ) + self.assertEqual(limits.max_events, trace._DEFAULT_SPAN_EVENTS_LIMIT) + self.assertEqual(limits.max_links, trace._DEFAULT_SPAN_LINKS_LIMIT) + + def test_limits_values_code(self): + max_attributes, max_events, max_links = ( + randint(0, 10000), + randint(0, 10000), + randint(0, 10000), + ) + limits = trace._Limits( + max_attributes=max_attributes, + max_events=max_events, + max_links=max_links, + ) + self.assertEqual(limits.max_attributes, max_attributes) + self.assertEqual(limits.max_events, max_events) + self.assertEqual(limits.max_links, max_links) + + def test_limits_values_env(self): + max_attributes, max_events, max_links = ( + randint(0, 10000), + randint(0, 10000), + randint(0, 10000), + ) + with mock.patch.dict( + "os.environ", + { + OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: str(max_attributes), + OTEL_SPAN_EVENT_COUNT_LIMIT: str(max_events), + OTEL_SPAN_LINK_COUNT_LIMIT: str(max_links), + }, + ): + limits = trace._Limits() + self.assertEqual(limits.max_attributes, max_attributes) + self.assertEqual(limits.max_events, max_events) + self.assertEqual(limits.max_links, max_links) + @mock.patch.dict( "os.environ", { @@ -1307,8 +1324,7 @@ class TestSpanLimits(unittest.TestCase): OTEL_SPAN_LINK_COUNT_LIMIT: "30", }, ) - def test_span_environment_limits(self): - reload(trace) + def test_span_limits_env(self): tracer = new_tracer() id_generator = RandomIdGenerator() some_links = [ @@ -1336,3 +1352,45 @@ def test_span_environment_limits(self): self.assertEqual(len(root.attributes), 10) self.assertEqual(len(root.events), 20) + + @mock.patch.dict( + "os.environ", + { + OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: "unset", + OTEL_SPAN_EVENT_COUNT_LIMIT: "unset", + OTEL_SPAN_LINK_COUNT_LIMIT: "unset", + }, + ) + def test_span_no_limits_env(self): + num_links = int(trace._DEFAULT_SPAN_LINKS_LIMIT) + randint(1, 100) + + tracer = new_tracer() + id_generator = RandomIdGenerator() + some_links = [ + trace_api.Link( + trace_api.SpanContext( + trace_id=id_generator.generate_trace_id(), + span_id=id_generator.generate_span_id(), + is_remote=False, + ) + ) + for _ in range(num_links) + ] + with tracer.start_as_current_span("root", links=some_links) as root: + self.assertEqual(len(root.links), num_links) + + num_events = int(trace._DEFAULT_SPAN_EVENTS_LIMIT) + randint(1, 100) + with tracer.start_as_current_span("root") as root: + for idx in range(num_events): + root.add_event("my_event_{}".format(idx)) + + self.assertEqual(len(root.events), num_events) + + num_attributes = int(trace._DEFAULT_SPAN_ATTRIBUTES_LIMIT) + randint( + 1, 100 + ) + with tracer.start_as_current_span("root") as root: + for idx in range(num_attributes): + root.set_attribute("my_attribute_{}".format(idx), 0) + + self.assertEqual(len(root.attributes), num_attributes)