diff --git a/ddtrace/internal/utils/formats.py b/ddtrace/internal/utils/formats.py index 1fd5f007e4a..02dd5381b06 100644 --- a/ddtrace/internal/utils/formats.py +++ b/ddtrace/internal/utils/formats.py @@ -63,7 +63,7 @@ def asbool(value: Union[str, bool, None]) -> bool: return value.lower() in ("true", "1") -def parse_tags_str(tags_str: Optional[str]) -> dict[str, str]: +def parse_tags_str(tags_str: Optional[str], sep: Optional[str] = None) -> dict[str, str]: """ Parses a string containing key-value pairs and returns a dictionary. Key-value pairs are delimited by ':', and pairs are separated by whitespace, comma, OR BOTH. @@ -71,13 +71,16 @@ def parse_tags_str(tags_str: Optional[str]) -> dict[str, str]: This implementation aligns with the way tags are parsed by the Agent and other Datadog SDKs :param tags_str: A string of the above form to parse tags from. + :param sep: An explicit pair separator to use instead of auto-detecting one. Callers whose + values may themselves contain whitespace (and no comma) should pass "," here to avoid an + incorrect whitespace-based split. :return: A dict containing the tags that were parsed. """ res: dict[str, str] = {} if not tags_str: return res # falling back to comma as separator - sep = "," if "," in tags_str else " " + sep = sep if sep is not None else ("," if "," in tags_str else " ") for tag in tags_str.split(sep): tag = tag.strip() diff --git a/ddtrace/llmobs/_writer.py b/ddtrace/llmobs/_writer.py index 0783ca90e76..6909008915e 100644 --- a/ddtrace/llmobs/_writer.py +++ b/ddtrace/llmobs/_writer.py @@ -216,7 +216,10 @@ def __init__( self._headers[EVP_SUBDOMAIN_HEADER_NAME] = self.EVP_SUBDOMAIN_HEADER_VALUE additional_header_str = env.get("_DD_TRACE_WRITER_ADDITIONAL_HEADERS", "") if additional_header_str: - self._headers.update(parse_tags_str(additional_header_str)) + # Explicit comma separator: header values (e.g. "Bearer ") may contain a space + # with no comma anywhere in the string, which would otherwise fall back to a whitespace + # split and corrupt the value. + self._headers.update(parse_tags_str(additional_header_str, sep=",")) self._send_payload_with_retry = fibonacci_backoff_with_jitter( attempts=self.RETRY_ATTEMPTS, diff --git a/releasenotes/notes/fix-additional-headers-whitespace-split-53067051dd9a93f2.yaml b/releasenotes/notes/fix-additional-headers-whitespace-split-53067051dd9a93f2.yaml new file mode 100644 index 00000000000..409fef2b3d2 --- /dev/null +++ b/releasenotes/notes/fix-additional-headers-whitespace-split-53067051dd9a93f2.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + LLM Observability: fixes an issue where ``_DD_TRACE_WRITER_ADDITIONAL_HEADERS`` header values + containing a whitespace (for example ``Bearer ``) were incorrectly parsed, resulting in + a truncated header value that caused LLM Observability spans to be dropped. diff --git a/tests/llmobs/test_llmobs_span_agentless_writer.py b/tests/llmobs/test_llmobs_span_agentless_writer.py index bcc06ef54e4..907a9964ebb 100644 --- a/tests/llmobs/test_llmobs_span_agentless_writer.py +++ b/tests/llmobs/test_llmobs_span_agentless_writer.py @@ -37,6 +37,38 @@ def test_additional_headers(mock_writer_logs): assert llmobs_span_writer._headers["Authorization"] == "Bearer-custom-token" +@pytest.mark.parametrize( + "additional_headers,expected_headers", + [ + # No comma: would wrongly fall back to a whitespace split. + ( + "Authorization:Bearer custom-token", + {"Authorization": "Bearer custom-token"}, + ), + # Trailing comma. + ( + "Authorization:Bearer custom-token,", + {"Authorization": "Bearer custom-token"}, + ), + # Multiple real-world headers, two with spaces in their value. + ( + 'Authorization:Bearer abc123xyz,User-Agent:"Datadog Tracer",X-Request-Id:req-12345', + { + "Authorization": "Bearer abc123xyz", + "User-Agent": '"Datadog Tracer"', + "X-Request-Id": "req-12345", + }, + ), + ], +) +def test_additional_headers_with_spaces(mock_writer_logs, additional_headers, expected_headers): + assert expected_headers + with mock.patch.dict(os.environ, {"_DD_TRACE_WRITER_ADDITIONAL_HEADERS": additional_headers}): + llmobs_span_writer = LLMObsSpanWriter(1, 1, is_agentless=True, _site=DD_SITE, _api_key=DD_API_KEY) + for key, value in expected_headers.items(): + assert llmobs_span_writer._headers[key] == value + + def test_no_additional_headers_by_default(mock_writer_logs): llmobs_span_writer = LLMObsSpanWriter(1, 1, is_agentless=True, _site=DD_SITE, _api_key=DD_API_KEY) assert "Authorization" not in llmobs_span_writer._headers diff --git a/tests/tracer/test_utils.py b/tests/tracer/test_utils.py index 06643c78a7c..486269dd290 100644 --- a/tests/tracer/test_utils.py +++ b/tests/tracer/test_utils.py @@ -67,6 +67,20 @@ def test_parse_env_tags(tag_str, expected_tags): assert parse_tags_str(tag_str) == expected_tags, tag_str +@pytest.mark.parametrize( + "tag_str,sep,expected_tags", + [ + # No comma anywhere: auto-detection would fall back to whitespace and split "Bearer ". + # An explicit "," separator keeps it as a single value. + ("Authorization:Bearer abc123xyz", ",", {"Authorization": "Bearer abc123xyz"}), + # Explicit " " separator forces a whitespace split even though a comma is present. + ("a:b,c bKey:bVal", " ", {"a": "b,c", "bKey": "bVal"}), + ], +) +def test_parse_env_tags_explicit_sep(tag_str, sep, expected_tags): + assert parse_tags_str(tag_str, sep=sep) == expected_tags, tag_str + + @pytest.mark.parametrize( "key,value,expected", [