Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions ddtrace/internal/utils/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,24 @@ 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.

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()
Expand Down
5 changes: 4 additions & 1 deletion ddtrace/llmobs/_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>") 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
fixes:
- |
LLM Observability: fixes an issue where ``_DD_TRACE_WRITER_ADDITIONAL_HEADERS`` header values
containing a whitespace (for example ``Bearer <token>``) were incorrectly parsed, resulting in
a truncated header value that caused LLM Observability spans to be dropped.
32 changes: 32 additions & 0 deletions tests/llmobs/test_llmobs_span_agentless_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/tracer/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>".
# 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",
[
Expand Down
Loading