Skip to content
Draft
24 changes: 15 additions & 9 deletions tests/parametric/test_otel_span_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from utils.docker_fixtures.spec.trace import retrieve_span_links
from utils.docker_fixtures.spec.trace import find_first_span_in_trace_payload
from utils.docker_fixtures import TestAgentAPI
from utils.dd_types import is_same_boolean
from utils import features, context, scenarios
from .conftest import APMLibrary

Expand Down Expand Up @@ -191,9 +192,14 @@ def test_otel_set_attributes_different_types_with_array_encoding(
assert root_span["name"] == "producer"
assert root_span["resource"] == "operation"

# v0.4 serializes data into `meta`;
# v1.0 serializes data into `metrics`;
# Merge both so lookups are protocol-agnostic.
meta_or_metrics = {**root_span["meta"], **root_span["metrics"]}

assert root_span["meta"]["str_val"] == "val"
assert root_span["meta"]["str_val_empty"] == ""
assert root_span["meta"]["bool_val"] == "true"
assert is_same_boolean(actual=meta_or_metrics.get("bool_val"), expected=True, is_otel_boolean=True)
assert root_span["metrics"]["int_val"] == 1
assert root_span["metrics"]["int_val_zero"] == 0
assert root_span["metrics"]["double_val"] == 4.2
Expand All @@ -204,14 +210,14 @@ def test_otel_set_attributes_different_types_with_array_encoding(
assert root_span["metrics"]["array_val_int.0"] == 10
assert root_span["metrics"]["array_val_int.1"] == 20

assert root_span["meta"]["array_val_bool.0"] == "true"
assert root_span["meta"]["array_val_bool.1"] == "false"
assert is_same_boolean(actual=meta_or_metrics.get("array_val_bool.0"), expected=True, is_otel_boolean=True)
assert is_same_boolean(actual=meta_or_metrics.get("array_val_bool.1"), expected=False, is_otel_boolean=True)

assert root_span["metrics"]["array_val_double.0"] == 10.1
assert root_span["metrics"]["array_val_double.1"] == 20.2

assert root_span["meta"]["d_str_val"] == "bye"
assert root_span["meta"]["d_bool_val"] == "false"
assert is_same_boolean(actual=meta_or_metrics.get("d_bool_val"), expected=False, is_otel_boolean=True)
assert root_span["metrics"]["d_int_val"] == 2
assert root_span["metrics"]["d_double_val"] == 3.14

Expand Down Expand Up @@ -470,13 +476,13 @@ def test_otel_span_started_with_link_from_other_spans(self, test_agent: TestAgen
assert link.get("trace_id_high") == int(root_tid, 16)
assert link.get("attributes") is None or len(link.get("attributes")) == 0
# Tracestate is not required, but if it is present, it must contain the linked span's tracestate
assert link.get("tracestate") is None or "dd=" in link.get("tracestate")
assert not link.get("tracestate") or "dd=" in link.get("tracestate")

link = span_links[1]
assert link.get("span_id") == first.get("span_id")
assert link.get("trace_id") == first.get("trace_id")
assert link.get("trace_id_high") == int(root_tid, 16)
assert link.get("tracestate") is None or "dd=" in link.get("tracestate")
assert not link.get("tracestate") or "dd=" in link.get("tracestate")

@pytest.mark.parametrize(
("expected_operation_name", "span_kind", "attributes"),
Expand Down Expand Up @@ -637,7 +643,7 @@ def test_otel_add_event_meta_serialization(self, test_agent: TestAgentAPI, test_

event1 = events[0]
assert event1.get("name") == "first_event"
assert "attributes" not in event1
assert not event1.get("attributes")

event2 = events[1]
assert event2.get("name") == "second_event"
Expand All @@ -650,9 +656,9 @@ def test_otel_add_event_meta_serialization(self, test_agent: TestAgentAPI, test_
assert event3["attributes"].get("int_val") == 1
assert event3["attributes"].get("string_val") == "2"

v04_v07_events = "span_events" in root_span
arrays_are_flattened = "int_array.0" in event3["attributes"]

if v04_v07_events:
if arrays_are_flattened:
assert event3["attributes"].get("int_array.0") == 3
assert event3["attributes"].get("int_array.1") == 4
assert event3["attributes"].get("string_array.0") == "5"
Expand Down
6 changes: 3 additions & 3 deletions tests/parametric/test_span_events.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import json
import pytest

from utils import scenarios, features, rfc
from utils.docker_fixtures.spec.trace import find_span, find_trace
from utils.docker_fixtures.spec.trace import find_span, find_trace, retrieve_span_events
from utils.docker_fixtures import TestAgentAPI
from .conftest import APMLibrary

Expand Down Expand Up @@ -121,7 +120,8 @@ def _test_span_with_meta_event(
assert len(trace) == 1
span = find_span(trace, s.span_id)

span_events = json.loads(span.get("meta", {}).get("events"))
span_events = retrieve_span_events(span)
assert span_events is not None

assert len(span_events) == 2

Expand Down
11 changes: 10 additions & 1 deletion tests/test_the_test/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -5641,6 +5641,15 @@
"tests/test_the_test/test_decorators.py::test_version_range": [
"TEST_THE_TEST"
],
"tests/test_the_test/test_dd_types.py::Test_DDTypes::test_is_same_boolean_accepts_native_and_string_booleans": [
"TEST_THE_TEST"
],
"tests/test_the_test/test_dd_types.py::Test_DDTypes::test_is_same_boolean_rejects_numeric_booleans_by_default": [
"TEST_THE_TEST"
],
"tests/test_the_test/test_dd_types.py::Test_DDTypes::test_is_same_boolean_accepts_numeric_booleans_in_otel_mode": [
"TEST_THE_TEST"
],
"tests/test_the_test/test_deserializer.py::test_deserialize_http_message": [
"TEST_THE_TEST"
],
Expand Down Expand Up @@ -5941,4 +5950,4 @@
"tests/test_the_test/test_version.py::test_php_version": [
"TEST_THE_TEST"
]
}
}
50 changes: 50 additions & 0 deletions tests/test_the_test/test_dd_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from utils import features, scenarios
from utils.dd_types import is_same_boolean


BooleanValue = bool | int | str | None


@scenarios.test_the_test
@features.not_reported
class Test_DDTypes:
def test_is_same_boolean_accepts_native_and_string_booleans(self) -> None:
cases: tuple[tuple[BooleanValue, BooleanValue], ...] = (
(True, "true"),
("true", True),
(False, "false"),
("false", False),
)

for actual, expected in cases:
assert is_same_boolean(actual=actual, expected=expected)

def test_is_same_boolean_rejects_numeric_booleans_by_default(self) -> None:
cases: tuple[tuple[BooleanValue, BooleanValue], ...] = (
(1, "true"),
(1, True),
(0, "false"),
(0, False),
("true", 1),
(True, 1),
("false", 0),
(False, 0),
)

for actual, expected in cases:
assert not is_same_boolean(actual=actual, expected=expected)

def test_is_same_boolean_accepts_numeric_booleans_in_otel_mode(self) -> None:
cases: tuple[tuple[BooleanValue, BooleanValue], ...] = (
(1, "true"),
(1, True),
(0, "false"),
(0, False),
("true", 1),
(True, 1),
("false", 0),
(False, 0),
)

for actual, expected in cases:
assert is_same_boolean(actual=actual, expected=expected, is_otel_boolean=True)
39 changes: 30 additions & 9 deletions utils/dd_types/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,35 @@ def get_rid_from_span_data(span_type: str, meta: dict, metrics: dict) -> str | N
return get_rid_from_user_agent(user_agent)


# Protocol v1.0 may deserialize meta booleans as True/False; older formats use "true"/"false".
def _normalize_for_compare(*, value: bool | str | None) -> str | None:
if value is True:
return "true"
if value is False:
return "false"
return value
def is_same_boolean(
*, actual: bool | int | str | None, expected: bool | int | str | None, is_otel_boolean: bool = False
) -> bool:
"""Compare two boolean-ish values that may arrive in different shapes.

Booleans reach the agent in several forms depending on the trace protocol and the source tag:
- older formats stringify them as "true"/"false";
- protocol v1.0 may deserialize as native True/False;
- OTel booleans may deserialize as 1/0.

def is_same_boolean(*, actual: bool | str | None, expected: bool | str | None) -> bool:
return _normalize_for_compare(value=actual) == _normalize_for_compare(value=expected)
The 1/0 form is only accepted when `is_otel_boolean=True`.
"""

def _normalize(*, value: bool | int | str | None) -> bool | int | str | None:
if value is True or value == "true":
return True
if value is False or value == "false":
return False
if is_otel_boolean:
if value == 1:
return True
if value == 0:
return False
return value # not a recognizable native boolean, will be compared by value as last resort

actual_value = _normalize(value=actual)
expected_value = _normalize(value=expected)

if isinstance(actual_value, bool) or isinstance(expected_value, bool):
return actual_value is expected_value

return actual_value == expected_value
31 changes: 24 additions & 7 deletions utils/docker_fixtures/spec/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
SAMPLING_LIMIT_PRIORITY_RATE = "_dd.limit_psr"

NUM_VALUES_IN_NATIVE_SPAN_ATTRIBUTE = 2
NATIVE_SPAN_ATTRIBUTE_ARRAY_TYPE = 4 # protocol v1 array value type


# Note that class attributes are golang style to match the payload.
Expand Down Expand Up @@ -291,18 +292,34 @@ def span_link_trace_ids_equal(a: int | str | None, b: int | str | None) -> bool:
return _span_link_trace_id_to_low64(a) == _span_link_trace_id_to_low64(b)


def _flatten_native_span_event_attribute(value: dict) -> object:
"""Flatten a native span event attribute value into a plain Python value.

Native span event attributes are typed dicts of the form:
``{"type": <int>, "<kind>_value": <value>}``.
Protocol v1 also supports array attributes, which are nested as:
``{"type": 4, "array_value": {"values": [<typed value>, ...]}}``
and must be flattened recursively into a plain list.
"""
assert len(value) == NUM_VALUES_IN_NATIVE_SPAN_ATTRIBUTE, (
f"native span event attribute has unexpected number of values: {value}"
)
is_array = value.get("type") == NATIVE_SPAN_ATTRIBUTE_ARRAY_TYPE
value.pop("type")
inner = next(iter(value.values()))
if is_array:
# `inner` is {"values": [<typed value>, ...]}; flatten each element recursively.
return [_flatten_native_span_event_attribute(item) for item in inner["values"]]
return inner


def retrieve_span_events(span: Span) -> list | None:
if span.get("span_events") is not None:
for event in span["span_events"]:
for key, value in event.get("attributes", {}).items():
if isinstance(value, dict):
# Flatten attributes dict into a single key-value pair
# This is for native span events
assert len(value) == NUM_VALUES_IN_NATIVE_SPAN_ATTRIBUTE, (
f"native span event has unexpected number of values: {event}"
)
value.pop("type")
event["attributes"][key] = next(iter(value.values()))
# Flatten typed attribute dicts into plain values (native span events).
event["attributes"][key] = _flatten_native_span_event_attribute(value)
else:
continue
return span["span_events"]
Expand Down
Loading