Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use BoundedAttributes for attributes in link, event, resource, spans #1915

Merged
merged 14 commits into from
Jun 25, 2021
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Attributes for `Link` and `Resource` are immutable as they are for `Event`, which means
any attempt to modify attributes directly will result in a `TypeError` exception.
([#1909](https://github.com/open-telemetry/opentelemetry-python/pull/1909))
- Added `BoundedDict` to the API to make it available for `Link` which is defined in the
API. Marked `BoundedDict` in the SDK as deprecated as a result.
([#1915](https://github.com/open-telemetry/opentelemetry-python/pull/1915))

## [1.3.0-0.22b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.3.0-0.22b0) - 2021-06-01

Expand Down
75 changes: 74 additions & 1 deletion opentelemetry-api/src/opentelemetry/attributes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
# type: ignore

import logging
import threading
from collections import OrderedDict
from collections.abc import MutableMapping
from types import MappingProxyType
from typing import MutableSequence, Sequence
from typing import MutableSequence, Optional, Sequence

from opentelemetry.util import types

Expand Down Expand Up @@ -108,3 +111,73 @@ def _create_immutable_attributes(
attributes: types.Attributes,
lzchen marked this conversation as resolved.
Show resolved Hide resolved
) -> types.Attributes:
return MappingProxyType(attributes.copy() if attributes else {})


_DEFAULT_LIMIT = 128


class BoundedDict(MutableMapping):
"""An ordered dict with a fixed max capacity.

Oldest elements are dropped when the dict is full and a new element is
added.
"""

def __init__(
self,
maxlen: Optional[int] = _DEFAULT_LIMIT,
attributes: types.Attributes = None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attributes makes sense in terms of what we are using BoundedDict for, but for the class itself it doesn't make much sense. Should we have a different name instead of BoundedDict?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to make a separation from the SDK BoundedDict too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I considered for a minute making Attributes a class to replace BoundedDict, wasn't sure if this would be doable without breaking Attributes' current interface though

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just BoundedAttributes or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

immutable: bool = True,
):
if maxlen is not None:
if not isinstance(maxlen, int):
raise ValueError
if maxlen < 0:
raise ValueError
lzchen marked this conversation as resolved.
Show resolved Hide resolved
self.maxlen = maxlen
self.dropped = 0
self._dict = OrderedDict() # type: OrderedDict
self._lock = threading.Lock() # type: threading.Lock
if attributes:
_filter_attributes(attributes)
for key, value in attributes.items():
self[key] = value
self._immutable = immutable

def __repr__(self):
return "{}({}, maxlen={})".format(
type(self).__name__, dict(self._dict), self.maxlen
)

def __getitem__(self, key):
return self._dict[key]

def __setitem__(self, key, value):
if getattr(self, "_immutable", False):
raise TypeError
with self._lock:
if self.maxlen is not None and self.maxlen == 0:
self.dropped += 1
return

if key in self._dict:
del self._dict[key]
elif self.maxlen is not None and len(self._dict) == self.maxlen:
del self._dict[next(iter(self._dict.keys()))]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self._dict.popitem(last=False)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what people's thoughts on this are. It's confusing to me that if the attributes dict is full, we delete an existing item from the dictionary in favour of the new item. Any thoughts @lzchen?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah my first impression when reading the specs is that the LATEST item that is attempting to be added would be dropped instead of popping the first item that was added. If we decide on changing the functionality, you could leave that for a separate PR if you wish.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do other SIGs do? And also spec says There SHOULD be a log emitted to indicate to the user that an attribute, event, or link was discarded due to such a limit. To prevent excessive logging, the log should not be emitted once per span, or per discarded attribute, event, or links. https://github.com/open-telemetry/opentelemetry-specification/blob/b46bcab5fb709381f1fd52096a19541370c7d1b3/specification/trace/sdk.md#span-limits

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like go does the same thing as we do currently. https://github.com/open-telemetry/opentelemetry-go/blob/c1f460e097d395fa7cd02acc4a6096d6c6f14b08/sdk/trace/attributesmap.go#L45-L62

I agree that for now it probably doesn't make sense to change the behaviour

self.dropped += 1
self._dict[key] = value

def __delitem__(self, key):
if getattr(self, "_immutable", False):
raise TypeError
del self._dict[key]
lzchen marked this conversation as resolved.
Show resolved Hide resolved

def __iter__(self):
with self._lock:
return iter(self._dict.copy())

def __len__(self):
return len(self._dict)

def copy(self):
return self._dict.copy()
8 changes: 3 additions & 5 deletions opentelemetry-api/src/opentelemetry/trace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@
from typing import Iterator, Optional, Sequence, cast

from opentelemetry import context as context_api
from opentelemetry.attributes import ( # type: ignore
_create_immutable_attributes,
)
from opentelemetry.attributes import BoundedDict # type: ignore
from opentelemetry.context.context import Context
from opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER
from opentelemetry.trace.propagation import (
Expand Down Expand Up @@ -142,8 +140,8 @@ def __init__(
attributes: types.Attributes = None,
) -> None:
super().__init__(context)
self._attributes = _create_immutable_attributes(
attributes
self._attributes = BoundedDict(
attributes=attributes
) # type: types.Attributes

@property
Expand Down
95 changes: 95 additions & 0 deletions opentelemetry-api/tests/attributes/test_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@

# type: ignore

import collections
import unittest

from opentelemetry.attributes import (
BoundedDict,
_create_immutable_attributes,
_filter_attributes,
_is_valid_attribute_value,
Expand Down Expand Up @@ -85,3 +87,96 @@ def test_create_immutable_attributes(self):
# TypeError: 'mappingproxy' object does not support item assignment
with self.assertRaises(TypeError):
immutable["pi"] = 1.34


class TestBoundedDict(unittest.TestCase):
base = collections.OrderedDict(
[
("name", "Firulais"),
("age", 7),
("weight", 13),
("vaccinated", True),
]
)

def test_negative_maxlen(self):
with self.assertRaises(ValueError):
BoundedDict(-1)

def test_from_map(self):
dic_len = len(self.base)
base_copy = collections.OrderedDict(self.base)
bdict = BoundedDict(dic_len, base_copy)

self.assertEqual(len(bdict), dic_len)

# modify base_copy and test that bdict is not changed
base_copy["name"] = "Bruno"
base_copy["age"] = 3

for key in self.base:
self.assertEqual(bdict[key], self.base[key])

# test that iter yields the correct number of elements
self.assertEqual(len(tuple(bdict)), dic_len)

# map too big
half_len = dic_len // 2
bdict = BoundedDict(half_len, self.base)
self.assertEqual(len(tuple(bdict)), half_len)
self.assertEqual(bdict.dropped, dic_len - half_len)

def test_bounded_dict(self):
# create empty dict
dic_len = len(self.base)
bdict = BoundedDict(dic_len, immutable=False)
self.assertEqual(len(bdict), 0)

# fill dict
for key in self.base:
bdict[key] = self.base[key]

self.assertEqual(len(bdict), dic_len)
self.assertEqual(bdict.dropped, 0)

for key in self.base:
self.assertEqual(bdict[key], self.base[key])

# test __iter__ in BoundedDict
for key in bdict:
self.assertEqual(bdict[key], self.base[key])

# updating an existing element should not drop
bdict["name"] = "Bruno"
self.assertEqual(bdict.dropped, 0)

# try to append more elements
for key in self.base:
bdict["new-" + key] = self.base[key]

self.assertEqual(len(bdict), dic_len)
self.assertEqual(bdict.dropped, dic_len)

# test that elements in the dict are the new ones
for key in self.base:
self.assertEqual(bdict["new-" + key], self.base[key])

# delete an element
del bdict["new-name"]
self.assertEqual(len(bdict), dic_len - 1)

with self.assertRaises(KeyError):
_ = bdict["new-name"]

def test_no_limit_code(self):
bdict = BoundedDict(maxlen=None, immutable=False)
for num in range(100):
bdict[num] = num

for num in range(100):
self.assertEqual(bdict[num], num)

def test_immutable(self):
bdict = BoundedDict()
with self.assertRaises(TypeError):
bdict["should-not-work"] = "dict immutable"
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,7 @@

import pkg_resources

from opentelemetry.attributes import (
_create_immutable_attributes,
_filter_attributes,
)
from opentelemetry.attributes import BoundedDict
from opentelemetry.sdk.environment_variables import (
OTEL_RESOURCE_ATTRIBUTES,
OTEL_SERVICE_NAME,
Expand Down Expand Up @@ -147,8 +144,7 @@ class Resource:
def __init__(
self, attributes: Attributes, schema_url: typing.Optional[str] = None
):
_filter_attributes(attributes)
self._attributes = _create_immutable_attributes(attributes)
self._attributes = BoundedDict(attributes=attributes)
if schema_url is None:
schema_url = ""
self._schema_url = schema_url
Expand Down
56 changes: 31 additions & 25 deletions opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@

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.attributes import BoundedDict, _is_valid_attribute_value
from opentelemetry.sdk import util
from opentelemetry.sdk.environment_variables import (
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT,
Expand All @@ -53,7 +49,7 @@
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import sampling
from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator
from opentelemetry.sdk.util import BoundedDict, BoundedList
from opentelemetry.sdk.util import BoundedList
from opentelemetry.sdk.util.instrumentation import InstrumentationInfo
from opentelemetry.trace import SpanContext
from opentelemetry.trace.status import Status, StatusCode
Expand All @@ -65,6 +61,8 @@
_DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT = 128
_DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT = 128
_DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT = 128
_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT = 128
_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT = 128


_ENV_VALUE_UNSET = "unset"
Expand Down Expand Up @@ -526,19 +524,27 @@ class SpanLimits:
max_links: Maximum number of links that can be added to a Span.
Environment variable: OTEL_SPAN_LINK_COUNT_LIMIT
Default: {_DEFAULT_SPAN_LINK_COUNT_LIMIT}
max_event_attributes: Maximum number of attributes that can be added to an Event.
Default: {_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT}
max_link_attributes: Maximum number of attributes that can be added to a Link.
Default: {_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT}
"""

UNSET = -1

max_attributes: int
max_events: int
max_links: int
max_event_attributes: int
max_link_attributes: int

def __init__(
self,
max_attributes: Optional[int] = None,
max_events: Optional[int] = None,
max_links: Optional[int] = None,
max_event_attributes: Optional[int] = None,
max_link_attributes: Optional[int] = None,
):
self.max_attributes = self._from_env_if_absent(
max_attributes,
Expand All @@ -555,6 +561,16 @@ def __init__(
OTEL_SPAN_LINK_COUNT_LIMIT,
_DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT,
)
self.max_event_attributes = self._from_env_if_absent(
max_event_attributes,
OTEL_SPAN_LINK_COUNT_LIMIT,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these placeholders until this is merged? Would be good to have a TODO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Captured the work in an issue #1918

_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT,
)
self.max_link_attributes = self._from_env_if_absent(
max_link_attributes,
OTEL_SPAN_LINK_COUNT_LIMIT,
_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT,
)

def __repr__(self):
return "max_attributes={}, max_events={}, max_links={}".format(
lzchen marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -591,6 +607,8 @@ def _from_env_if_absent(
max_attributes=SpanLimits.UNSET,
max_events=SpanLimits.UNSET,
max_links=SpanLimits.UNSET,
max_event_attributes=SpanLimits.UNSET,
max_link_attributes=SpanLimits.UNSET,
)

SPAN_ATTRIBUTE_COUNT_LIMIT = SpanLimits._from_env_if_absent(
Expand Down Expand Up @@ -661,22 +679,14 @@ def __init__(
self._span_processor = span_processor
self._limits = limits
self._lock = threading.Lock()

_filter_attributes(attributes)
if not attributes:
self._attributes = self._new_attributes()
else:
self._attributes = BoundedDict.from_map(
self._limits.max_attributes, attributes
)

self._attributes = BoundedDict(
self._limits.max_attributes, attributes, immutable=False
)
self._events = self._new_events()
if events:
for event in events:
_filter_attributes(event.attributes)
# pylint: disable=protected-access
event._attributes = _create_immutable_attributes(
event.attributes
event._attributes = BoundedDict(
self._limits.max_event_attributes, event.attributes
)
self._events.append(event)

Expand All @@ -690,9 +700,6 @@ def __repr__(self):
type(self).__name__, self._name, self._context
)

def _new_attributes(self):
return BoundedDict(self._limits.max_attributes)

def _new_events(self):
return BoundedList(self._limits.max_events)

Expand Down Expand Up @@ -745,13 +752,12 @@ def add_event(
attributes: types.Attributes = None,
timestamp: Optional[int] = None,
) -> None:
_filter_attributes(attributes)
attributes = _create_immutable_attributes(attributes)
attributes = BoundedDict(self._limits.max_event_attributes, attributes)
self._add_event(
Event(
name=name,
attributes=attributes,
timestamp=_time_ns() if timestamp is None else timestamp,
timestamp=timestamp,
srikanthccv marked this conversation as resolved.
Show resolved Hide resolved
)
)

Expand Down
Loading