From 20aa79a8dbe079b9a55b994326e87ac050497f06 Mon Sep 17 00:00:00 2001 From: Florian Leitner <62555304+leitneratselerity@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:11:46 +0200 Subject: [PATCH 1/2] fix: list-valued association properties crash metrics --- .../tests/test_metrics_common_attributes.py | 54 +++++++++++++++++++ .../traceloop/sdk/tracing/tracing.py | 4 ++ 2 files changed, 58 insertions(+) create mode 100644 packages/traceloop-sdk/tests/test_metrics_common_attributes.py diff --git a/packages/traceloop-sdk/tests/test_metrics_common_attributes.py b/packages/traceloop-sdk/tests/test_metrics_common_attributes.py new file mode 100644 index 0000000000..34a6677636 --- /dev/null +++ b/packages/traceloop-sdk/tests/test_metrics_common_attributes.py @@ -0,0 +1,54 @@ +import json + +from opentelemetry.context import attach, set_value +from opentelemetry.semconv_ai import SpanAttributes + +from traceloop.sdk.tracing.tracing import metrics_common_attributes + + +def test_scalar_association_properties_pass_through(): + attach(set_value("association_properties", {"user_id": 1, "user_name": "John"})) + + attributes = metrics_common_attributes() + + assert attributes[f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.user_id"] == 1 + assert ( + attributes[f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.user_name"] + == "John" + ) + + +def test_list_valued_association_property_is_json_encoded(): + """LangGraph tags spans with list-valued baggage (e.g. langgraph_triggers). + Metric attributes must be hashable/scalar, so a raw list here previously + crashed the OTel metrics SDK's aggregation and silently dropped every + response-side span attribute set after it (gen_ai.output.messages, etc.).""" + attach( + set_value( + "association_properties", + {"langgraph_triggers": ["branch:to:resolve_naics"]}, + ) + ) + + attributes = metrics_common_attributes() + + key = f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.langgraph_triggers" + assert attributes[key] == json.dumps(["branch:to:resolve_naics"]) + # must be hashable, matching what the OTel metrics SDK requires for its + # aggregation key: frozenset(attributes.items()) + hash(attributes[key]) + + +def test_dict_valued_association_property_is_json_encoded(): + attach( + set_value( + "association_properties", + {"metadata": {"nested": "value"}}, + ) + ) + + attributes = metrics_common_attributes() + + key = f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.metadata" + assert attributes[key] == json.dumps({"nested": "value"}) + hash(attributes[key]) diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 8808f85eab..a43fa6ee85 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -1,4 +1,5 @@ import atexit +import json import logging import os from urllib.parse import urlparse @@ -1221,6 +1222,9 @@ def metrics_common_attributes(): association_properties = get_value("association_properties") if association_properties is not None: for key, value in association_properties.items(): + if isinstance(value, (list, dict)): + # OTel metric attributes must be scalar/hashable + value = json.dumps(value) common_attributes[ f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.{key}" ] = value From 41094118c110bf7dfa8c3ee84104b94845cfa5f8 Mon Sep 17 00:00:00 2001 From: Florian Leitner <62555304+leitneratselerity@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:00:34 +0200 Subject: [PATCH 2/2] fix: preserve ordering of assertion_properties key If a caller sets association_properties with a dict/list value whose key (insertion) order varies across calls, the same conceptual value produces two different string attribute values. Sorting the keys before serializing prevents this subtle bug. --- .../tests/test_metrics_common_attributes.py | 27 ++++++++++++++++++- .../traceloop/sdk/tracing/tracing.py | 3 +-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/traceloop-sdk/tests/test_metrics_common_attributes.py b/packages/traceloop-sdk/tests/test_metrics_common_attributes.py index 34a6677636..b8e62b578d 100644 --- a/packages/traceloop-sdk/tests/test_metrics_common_attributes.py +++ b/packages/traceloop-sdk/tests/test_metrics_common_attributes.py @@ -50,5 +50,30 @@ def test_dict_valued_association_property_is_json_encoded(): attributes = metrics_common_attributes() key = f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.metadata" - assert attributes[key] == json.dumps({"nested": "value"}) + assert attributes[key] == json.dumps({"nested": "value"}, sort_keys=True) hash(attributes[key]) + + +def test_dict_valued_association_property_is_order_independent(): + """Equal dicts with different key insertion order must serialize to the same + string, otherwise the metrics SDK treats them as distinct attribute values and + splits what should be one aggregated series into several.""" + attach( + set_value( + "association_properties", + {"metadata": {"a": 1, "b": 2}}, + ) + ) + attributes_a = metrics_common_attributes() + + attach( + set_value( + "association_properties", + {"metadata": {"b": 2, "a": 1}}, + ) + ) + attributes_b = metrics_common_attributes() + + key = f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.metadata" + assert attributes_a[key] == attributes_b[key] + diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index a43fa6ee85..cd8b8c6062 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -1223,8 +1223,7 @@ def metrics_common_attributes(): if association_properties is not None: for key, value in association_properties.items(): if isinstance(value, (list, dict)): - # OTel metric attributes must be scalar/hashable - value = json.dumps(value) + value = json.dumps(value, sort_keys=True) common_attributes[ f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.{key}" ] = value