diff --git a/tests/parametric/test_otel_span_methods.py b/tests/parametric/test_otel_span_methods.py index 5c448fcac0e..eafefb17d68 100644 --- a/tests/parametric/test_otel_span_methods.py +++ b/tests/parametric/test_otel_span_methods.py @@ -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 @@ -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 @@ -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 @@ -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"), @@ -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" @@ -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" diff --git a/tests/parametric/test_span_events.py b/tests/parametric/test_span_events.py index 2cb0d05464d..96b64120511 100644 --- a/tests/parametric/test_span_events.py +++ b/tests/parametric/test_span_events.py @@ -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 @@ -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 diff --git a/tests/test_the_test/scenarios.json b/tests/test_the_test/scenarios.json index 2e0f35c0312..40c5ed3e46b 100644 --- a/tests/test_the_test/scenarios.json +++ b/tests/test_the_test/scenarios.json @@ -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" ], @@ -5941,4 +5950,4 @@ "tests/test_the_test/test_version.py::test_php_version": [ "TEST_THE_TEST" ] -} \ No newline at end of file +} diff --git a/tests/test_the_test/test_dd_types.py b/tests/test_the_test/test_dd_types.py new file mode 100644 index 00000000000..ce6d25072ca --- /dev/null +++ b/tests/test_the_test/test_dd_types.py @@ -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) diff --git a/utils/dd_types/_utils.py b/utils/dd_types/_utils.py index 0918ef3f4ee..052b8f5ded3 100644 --- a/utils/dd_types/_utils.py +++ b/utils/dd_types/_utils.py @@ -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 diff --git a/utils/docker_fixtures/spec/trace.py b/utils/docker_fixtures/spec/trace.py index e55fffe5f8e..3211f493d05 100644 --- a/utils/docker_fixtures/spec/trace.py +++ b/utils/docker_fixtures/spec/trace.py @@ -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. @@ -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": , "_value": }``. + Protocol v1 also supports array attributes, which are nested as: + ``{"type": 4, "array_value": {"values": [, ...]}}`` + 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": [, ...]}; 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"]