Skip to content

Commit b50e35e

Browse files
authored
Merge pull request BerriAI#18319 from BerriAI/litellm_feat_datadog_log_trace_linking
feat: datadog log trace linking
2 parents 955e843 + 41bbb3a commit b50e35e

3 files changed

Lines changed: 199 additions & 16 deletions

File tree

litellm/integrations/datadog/datadog.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
get_datadog_source,
3434
get_datadog_tags,
3535
)
36+
from litellm.litellm_core_utils.dd_tracing import tracer
3637
from litellm.llms.custom_httpx.http_handler import (
3738
_get_httpx_client,
3839
get_async_httpx_client,
@@ -337,6 +338,7 @@ def _create_datadog_logging_payload_helper(
337338
service=get_datadog_service(),
338339
status=status,
339340
)
341+
self._add_trace_context_to_payload(dd_payload=dd_payload)
340342
return dd_payload
341343

342344
def create_datadog_logging_payload(
@@ -574,6 +576,56 @@ def _create_v0_logging_payload(
574576
)
575577
return dd_payload
576578

579+
def _add_trace_context_to_payload(
580+
self,
581+
dd_payload: DatadogPayload,
582+
) -> None:
583+
"""Attach Datadog APM trace context if one is active."""
584+
585+
try:
586+
trace_context = self._get_active_trace_context()
587+
if trace_context is None:
588+
return
589+
590+
dd_payload["dd.trace_id"] = trace_context["trace_id"]
591+
span_id = trace_context.get("span_id")
592+
if span_id is not None:
593+
dd_payload["dd.span_id"] = span_id
594+
except Exception:
595+
verbose_logger.exception(
596+
"Datadog: Failed to attach trace context to payload"
597+
)
598+
599+
def _get_active_trace_context(self) -> Optional[Dict[str, str]]:
600+
try:
601+
current_span = None
602+
current_span_fn = getattr(tracer, "current_span", None)
603+
if callable(current_span_fn):
604+
current_span = current_span_fn()
605+
606+
if current_span is None:
607+
current_root_span_fn = getattr(tracer, "current_root_span", None)
608+
if callable(current_root_span_fn):
609+
current_span = current_root_span_fn()
610+
611+
if current_span is None:
612+
return None
613+
614+
trace_id = getattr(current_span, "trace_id", None)
615+
if trace_id is None:
616+
return None
617+
618+
span_id = getattr(current_span, "span_id", None)
619+
trace_context: Dict[str, str] = {"trace_id": str(trace_id)}
620+
if span_id is not None:
621+
trace_context["span_id"] = str(span_id)
622+
return trace_context
623+
except Exception:
624+
verbose_logger.exception(
625+
"Datadog: Failed to retrieve active trace context from tracer"
626+
)
627+
return None
628+
577629
async def async_health_check(self) -> IntegrationHealthCheckStatus:
578630
"""
579631
Check if the service is healthy

litellm/types/integrations/datadog.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import Enum
22
from typing import Optional
33

4-
from typing_extensions import TypedDict
4+
from typing_extensions import NotRequired, TypedDict
55

66
from litellm.types.integrations.custom_logger import StandardCustomLoggerInitParams
77

@@ -14,13 +14,20 @@ class DataDogStatus(str, Enum):
1414
ERROR = "error"
1515

1616

17-
class DatadogPayload(TypedDict, total=False):
18-
ddsource: str
19-
ddtags: str
20-
hostname: str
21-
message: str
22-
service: str
23-
status: str
17+
DatadogPayload = TypedDict(
18+
"DatadogPayload",
19+
{
20+
"ddsource": str,
21+
"ddtags": str,
22+
"hostname": str,
23+
"message": str,
24+
"service": str,
25+
"status": str,
26+
"dd.trace_id": NotRequired[str],
27+
"dd.span_id": NotRequired[str],
28+
},
29+
total=False,
30+
)
2431

2532

2633
class DD_ERRORS(Enum):

tests/logging_callback_tests/test_datadog.py

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from litellm import completion
2727
from litellm._logging import verbose_logger
2828
from litellm.integrations.datadog.datadog import *
29+
import litellm.integrations.datadog.datadog as datadog_module
2930
from datetime import datetime, timedelta
3031
from litellm.types.utils import (
3132
StandardLoggingPayload,
@@ -90,6 +91,24 @@ def create_standard_logging_payload() -> StandardLoggingPayload:
9091
)
9192

9293

94+
class _DummySpan:
95+
def __init__(self, trace_id=None, span_id=None):
96+
self.trace_id = trace_id
97+
self.span_id = span_id
98+
99+
100+
class _DummyTracer:
101+
def __init__(self, current_span=None, current_root_span=None):
102+
self._current_span = current_span
103+
self._current_root_span = current_root_span
104+
105+
def current_span(self):
106+
return self._current_span
107+
108+
def current_root_span(self):
109+
return self._current_root_span
110+
111+
93112
@pytest.mark.asyncio
94113
async def test_create_datadog_logging_payload():
95114
"""Test creating a DataDog logging payload from a standard logging object"""
@@ -219,20 +238,35 @@ async def test_datadog_logging_http_request():
219238

220239
# Get the expected fields and their types from DatadogPayload
221240
expected_fields = DatadogPayload.__annotations__
222-
# Assert that all elements in body have the fields of DatadogPayload with correct types
241+
required_fields = {
242+
"ddsource": str,
243+
"ddtags": str,
244+
"hostname": str,
245+
"message": str,
246+
"service": str,
247+
"status": str,
248+
}
249+
optional_fields = set(expected_fields.keys()) - set(required_fields.keys())
250+
251+
# Assert that all elements in body have the required fields with correct types
223252
for log in body:
224253
assert isinstance(log, dict), "Each log should be a dictionary"
225-
for field, expected_type in expected_fields.items():
254+
for field, expected_type in required_fields.items():
226255
assert field in log, f"Field '{field}' is missing from the log"
227256
assert isinstance(
228257
log[field], expected_type
229258
), f"Field '{field}' has incorrect type. Expected {expected_type}, got {type(log[field])}"
230259

231-
# Additional assertion to ensure no extra fields are present
232-
for log in body:
233-
assert set(log.keys()) == set(
234-
expected_fields.keys()
235-
), f"Log contains unexpected fields: {set(log.keys()) - set(expected_fields.keys())}"
260+
for optional_field in optional_fields:
261+
if optional_field in log:
262+
assert isinstance(
263+
log[optional_field], str
264+
), f"Optional field '{optional_field}' must be a string"
265+
266+
unexpected_fields = set(log.keys()) - set(expected_fields.keys())
267+
assert (
268+
not unexpected_fields
269+
), f"Log contains unexpected fields: {unexpected_fields}"
236270

237271
# Parse the 'message' field as JSON and check its structure
238272
message = json.loads(body[0]["message"])
@@ -256,6 +290,96 @@ async def test_datadog_logging_http_request():
256290
pytest.fail(f"Test failed with exception: {str(e)}")
257291

258292

293+
@pytest.mark.asyncio
294+
async def test_add_trace_context_uses_current_span(monkeypatch):
295+
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
296+
monkeypatch.setenv("DD_API_KEY", "anything")
297+
tracer = _DummyTracer(current_span=_DummySpan(trace_id=123, span_id=456))
298+
monkeypatch.setattr(datadog_module, "tracer", tracer)
299+
300+
dd_logger = DataDogLogger()
301+
payload = DatadogPayload(
302+
ddsource="litellm",
303+
ddtags="env:test",
304+
hostname="host",
305+
message="{}",
306+
service="svc",
307+
status="info",
308+
)
309+
310+
dd_logger._add_trace_context_to_payload(payload)
311+
assert payload["dd.trace_id"] == "123"
312+
assert payload["dd.span_id"] == "456"
313+
314+
315+
@pytest.mark.asyncio
316+
async def test_add_trace_context_falls_back_to_root_span(monkeypatch):
317+
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
318+
monkeypatch.setenv("DD_API_KEY", "anything")
319+
tracer = _DummyTracer(
320+
current_span=None,
321+
current_root_span=_DummySpan(trace_id=789, span_id=None),
322+
)
323+
monkeypatch.setattr(datadog_module, "tracer", tracer)
324+
325+
dd_logger = DataDogLogger()
326+
payload = DatadogPayload(
327+
ddsource="litellm",
328+
ddtags="env:test",
329+
hostname="host",
330+
message="{}",
331+
service="svc",
332+
status="info",
333+
)
334+
335+
dd_logger._add_trace_context_to_payload(payload)
336+
assert payload["dd.trace_id"] == "789"
337+
assert "dd.span_id" not in payload
338+
339+
340+
@pytest.mark.asyncio
341+
async def test_add_trace_context_handles_missing_tracer(monkeypatch):
342+
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
343+
monkeypatch.setenv("DD_API_KEY", "anything")
344+
monkeypatch.setattr(datadog_module, "tracer", object())
345+
346+
dd_logger = DataDogLogger()
347+
payload = DatadogPayload(
348+
ddsource="litellm",
349+
ddtags="env:test",
350+
hostname="host",
351+
message="{}",
352+
service="svc",
353+
status="info",
354+
)
355+
356+
dd_logger._add_trace_context_to_payload(payload)
357+
assert "dd.trace_id" not in payload
358+
assert "dd.span_id" not in payload
359+
360+
361+
@pytest.mark.asyncio
362+
async def test_add_trace_context_ignores_span_without_trace_id(monkeypatch):
363+
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
364+
monkeypatch.setenv("DD_API_KEY", "anything")
365+
tracer = _DummyTracer(current_span=_DummySpan(trace_id=None, span_id=555))
366+
monkeypatch.setattr(datadog_module, "tracer", tracer)
367+
368+
dd_logger = DataDogLogger()
369+
payload = DatadogPayload(
370+
ddsource="litellm",
371+
ddtags="env:test",
372+
hostname="host",
373+
message="{}",
374+
service="svc",
375+
status="info",
376+
)
377+
378+
dd_logger._add_trace_context_to_payload(payload)
379+
assert "dd.trace_id" not in payload
380+
assert "dd.span_id" not in payload
381+
382+
259383
@pytest.mark.asyncio
260384
async def test_datadog_log_redis_failures():
261385
"""
@@ -701,4 +825,4 @@ def test_datadog_ignores_ddtrace_agent_host():
701825
)
702826

703827
# Verify API key is set correctly
704-
assert dd_logger.DD_API_KEY == "fake-api-key"
828+
assert dd_logger.DD_API_KEY == "fake-api-key"

0 commit comments

Comments
 (0)