Skip to content

Commit 7c75365

Browse files
authored
chore(opentelemetry): support tag flattening [AIT-9352] (#8042)
Ensures otel tags/attribute values with the type List, Tuple and/or set are flattened. This operation is already done by the Datadog UI when displaying tags. However we should also do this in the tracer to better align with other languages. The flattening logic must follow this spec: ``` Array values must decay into as many keys as there are entries in the array. The keys must be mapped by concatenating the outer and inner key values together, recursively, separated by a dot. In pseudo-code: fn addArrayAttribute(key, array): for (subkey, value) in array: addScalarOrArrayAttribute(key + "." + subkey, value) Example: Given attributes {"key": [[1,2], ["3", "4"]]}, we will have {"key.0.0": "1", "key.0.1": 2, "key.1.0": 3, "key.1.1":4}. ``` ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [ ] Title is accurate. - [ ] No unnecessary changes are introduced. - [ ] Description motivates each change. - [ ] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [ ] Testing strategy adequately addresses listed risk(s). - [ ] Change is maintainable (easy to change, telemetry, documentation). - [ ] Release note makes sense to a user of the library. - [ ] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [ ] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) - [ ] If this PR touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`. - [ ] This PR doesn't touch any of that.
1 parent fbeba87 commit 7c75365

File tree

8 files changed

+151
-8
lines changed

8 files changed

+151
-8
lines changed

ddtrace/internal/utils/formats.py

+26
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
T = TypeVar("T")
2121

22+
2223
log = logging.getLogger(__name__)
2324

2425

@@ -157,3 +158,28 @@ def stringify_cache_args(args, value_max_len=VALUE_MAX_LEN, cmd_max_len=CMD_MAX_
157158
break
158159

159160
return " ".join(out)
161+
162+
163+
def is_sequence(obj):
164+
# type: (Any) -> bool
165+
try:
166+
return isinstance(obj, (list, tuple, set, frozenset))
167+
except TypeError:
168+
# Checking the type of Generic Subclasses raises a TypeError
169+
return False
170+
171+
172+
def flatten_key_value(root_key, value):
173+
# type: (str, Any) -> Dict[str, Any]
174+
"""Flattens attributes"""
175+
if not is_sequence(value):
176+
return {root_key: value}
177+
178+
flattened = dict()
179+
for i, item in enumerate(value):
180+
key = f"{root_key}.{i}"
181+
if is_sequence(item):
182+
flattened.update(flatten_key_value(key, item))
183+
else:
184+
flattened[key] = item
185+
return flattened

ddtrace/opentelemetry/_span.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
from ddtrace.internal import core
1414
from ddtrace.internal.compat import time_ns
1515
from ddtrace.internal.logger import get_logger
16+
from ddtrace.internal.utils.formats import flatten_key_value
17+
from ddtrace.internal.utils.formats import is_sequence
1618

1719

1820
if TYPE_CHECKING:
19-
from typing import Callable # noqa:F401
2021
from typing import Mapping # noqa:F401
2122
from typing import Optional # noqa:F401
2223
from typing import Union # noqa:F401
@@ -160,6 +161,10 @@ def set_attribute(self, key, value):
160161
_ddmap(self._ddspan, ddattribute, value)
161162
return
162163

164+
if is_sequence(value):
165+
for k, v in flatten_key_value(key, value).items():
166+
self._ddspan.set_tag(k, v)
167+
return
163168
self._ddspan.set_tag(key, value)
164169

165170
def add_event(self, name, attributes=None, timestamp=None):

ddtrace/tracing/_span_link.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
import attr
3030

31+
from ddtrace.internal.utils.formats import flatten_key_value
32+
3133

3234
def _id_not_zero(self, attribute, value):
3335
if not value > 0:
@@ -84,7 +86,13 @@ def to_dict(self):
8486
"span_id": "{:016x}".format(self.span_id),
8587
}
8688
if self.attributes:
87-
d["attributes"] = {k: str(v) for k, v in self.attributes.items()}
89+
d["attributes"] = {}
90+
for k, v in self.attributes.items():
91+
# flatten all values with the type list, tuple and set
92+
for k1, v1 in flatten_key_value(k, v).items():
93+
# convert all values to string
94+
d["attributes"][k1] = str(v1)
95+
8896
if self._dropped_attributes > 0:
8997
d["dropped_attributes_count"] = self._dropped_attributes
9098
if self.tracestate:

tests/opentelemetry/test_span.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from tests.utils import flaky
1616

1717

18-
@pytest.mark.snapshot(wait_for_num_traces=2)
18+
@pytest.mark.snapshot(wait_for_num_traces=3)
1919
def test_otel_span_attributes(oteltracer):
2020
with oteltracer.start_span("otel-string-tags") as span1:
2121
span1.set_attribute("service.name", "moons-service-str")
@@ -32,6 +32,13 @@ def test_otel_span_attributes(oteltracer):
3232
span2.set_attributes({"tag1": 1, "tag2": 2, "tag3": 3.1415})
3333
span2.end()
3434

35+
with oteltracer.start_span("otel-list-tags") as span:
36+
span.set_attribute("moon1", [1, 2, 3])
37+
span.set_attribute("moon", [True, 2, ["hello", 4, ["5", "6asda"]]])
38+
span.set_attribute("sunk", (1, 2, 3))
39+
span.set_attribute("teardrop68", {1, 2, 3})
40+
span.set_attribute("gamer421", frozenset({1, 2, 3}))
41+
3542
# Attributes should not be set on a closed span
3643
for span in [span1, span2]:
3744
span.set_attribute("should_not_be_set", "attributes can not be added after a span is ended")

tests/snapshots/tests.opentelemetry.test_span.test_otel_span_attributes.json

+45-2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,49 @@
5757
"tag2": 2,
5858
"tag3": 3.1415
5959
},
60-
"duration": 118958,
61-
"start": 1700080043693385092
60+
"duration": 68000,
61+
"start": 1704737749273699000
62+
}],
63+
[
64+
{
65+
"name": "internal",
66+
"service": "",
67+
"resource": "otel-list-tags",
68+
"trace_id": 2,
69+
"span_id": 1,
70+
"parent_id": 0,
71+
"type": "",
72+
"error": 0,
73+
"meta": {
74+
"_dd.p.dm": "-0",
75+
"_dd.p.tid": "659c3bd500000000",
76+
"language": "python",
77+
"moon.0": "True",
78+
"moon.2.0": "hello",
79+
"moon.2.2.0": "5",
80+
"moon.2.2.1": "6asda",
81+
"runtime-id": "4b2f5598b7cb468fab14c8b0b52221bc"
82+
},
83+
"metrics": {
84+
"_dd.top_level": 1,
85+
"_dd.tracer_kr": 1.0,
86+
"_sampling_priority_v1": 1,
87+
"gamer421.0": 1,
88+
"gamer421.1": 2,
89+
"gamer421.2": 3,
90+
"moon.1": 2,
91+
"moon.2.1": 4,
92+
"moon1.0": 1,
93+
"moon1.1": 2,
94+
"moon1.2": 3,
95+
"process_id": 47933,
96+
"sunk.0": 1,
97+
"sunk.1": 2,
98+
"sunk.2": 3,
99+
"teardrop68.0": 1,
100+
"teardrop68.1": 2,
101+
"teardrop68.2": 3
102+
},
103+
"duration": 242000,
104+
"start": 1704737749273824000
62105
}]]

tests/tracer/test_encoders.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ def test_span_link_v04_encoding():
442442
"link.kind": "link_kind",
443443
"someval": 1,
444444
"drop_me": "bye",
445+
"key_other": [True, 2, ["hello", 4, {"5"}]],
445446
},
446447
)
447448
],
@@ -470,6 +471,11 @@ def test_span_link_v04_encoding():
470471
b"link.name": b"link_name",
471472
b"link.kind": b"link_kind",
472473
b"someval": b"1",
474+
b"key_other.0": b"True",
475+
b"key_other.1": b"2",
476+
b"key_other.2.0": b"hello",
477+
b"key_other.2.1": b"4",
478+
b"key_other.2.2.0": b"5",
473479
},
474480
b"dropped_attributes_count": 1,
475481
b"tracestate": b"congo=t61rcWkgMzE",
@@ -491,7 +497,13 @@ def test_span_link_v05_encoding():
491497
span_id=(2**64) - 1,
492498
tracestate="congo=t61rcWkgMzE",
493499
flags=0,
494-
attributes={"moon": "ears", "link.name": "link_name", "link.kind": "link_kind", "drop_me": "bye"},
500+
attributes={
501+
"moon": "ears",
502+
"link.name": "link_name",
503+
"link.kind": "link_kind",
504+
"drop_me": "bye",
505+
"key2": [True, 2, ["hello", 4, {"5"}]],
506+
},
495507
)
496508
],
497509
)
@@ -513,7 +525,8 @@ def test_span_link_v05_encoding():
513525
assert (
514526
encoded_span_meta[b"_dd.span_links"] == b'[{"trace_id": "7fffffffffffffffffffffffffffffff", '
515527
b'"span_id": "ffffffffffffffff", "attributes": {"moon": "ears", "link.name": "link_name", "link.kind": '
516-
b'"link_kind"}, "dropped_attributes_count": 1, "tracestate": "congo=t61rcWkgMzE", "flags": 0}]'
528+
b'"link_kind", "key2.0": "True", "key2.1": "2", "key2.2.0": "hello", "key2.2.1": "4", "key2.2.2.0": "5"}, '
529+
b'"dropped_attributes_count": 1, "tracestate": "congo=t61rcWkgMzE", "flags": 0}]'
517530
)
518531

519532

tests/tracer/test_span.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,12 @@ def test_span_links(self):
367367
s2.context._meta["tracestate"] = "congo=t61rcWkgMzE"
368368
s2.context.sampling_priority = 1
369369

370-
link_attributes = {"link.name": "s1_to_s2", "link.kind": "scheduled_by", "key1": "value2"}
370+
link_attributes = {
371+
"link.name": "s1_to_s2",
372+
"link.kind": "scheduled_by",
373+
"key1": "value2",
374+
"key2": [True, 2, ["hello", 4, ["5", "6asda"]]],
375+
}
371376
s1.link_span(s2.context, link_attributes)
372377

373378
assert s1._links == [

tests/tracer/test_utils.py

+36
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from ddtrace.internal.utils.cache import cachedmethod
1717
from ddtrace.internal.utils.cache import callonce
1818
from ddtrace.internal.utils.formats import asbool
19+
from ddtrace.internal.utils.formats import flatten_key_value
20+
from ddtrace.internal.utils.formats import is_sequence
1921
from ddtrace.internal.utils.formats import parse_tags_str
2022
from ddtrace.internal.utils.http import w3c_get_dd_list_member
2123
from ddtrace.internal.utils.importlib import func_name
@@ -96,6 +98,40 @@ def test_parse_env_tags(tag_str, expected_tags, error_calls):
9698
assert log.error.call_count == 0, log.error.call_args_list
9799

98100

101+
@pytest.mark.parametrize(
102+
"key,value,expected",
103+
[
104+
("a", "1", {"a": "1"}),
105+
("a", set("0"), {"a.0": "0"}),
106+
("a", frozenset("0"), {"a.0": "0"}),
107+
("a", ["0", "1", "2", "3"], {"a.0": "0", "a.1": "1", "a.2": "2", "a.3": "3"}),
108+
("a", ("0", "1", "2", "3"), {"a.0": "0", "a.1": "1", "a.2": "2", "a.3": "3"}),
109+
(
110+
"a",
111+
["0", {"1"}, ("2",), ["3", "4", ["5"]]],
112+
{"a.0": "0", "a.1.0": "1", "a.2.0": "2", "a.3.0": "3", "a.3.1": "4", "a.3.2.0": "5"},
113+
),
114+
],
115+
)
116+
def test_flatten_key_value_pairs(key, value, expected):
117+
assert flatten_key_value(key, value) == expected
118+
119+
120+
@pytest.mark.parametrize(
121+
"value,expected",
122+
[
123+
(("0", "1"), True),
124+
(["0", "1"], True),
125+
({"0", "1"}, True),
126+
(frozenset(["0", "1"]), True),
127+
("123", False),
128+
({"a": "1"}, False),
129+
],
130+
)
131+
def test_is_sequence(value, expected):
132+
assert is_sequence(value) == expected
133+
134+
99135
def test_no_states():
100136
watch = time.StopWatch()
101137
with pytest.raises(RuntimeError):

0 commit comments

Comments
 (0)