Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions drift/core/tracing/td_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,11 @@ class TdSpanAttributes:
TRANSFORM_METADATA = "td.transform_metadata"
STACK_TRACE = "td.stack_trace"

# Export control
# Set by framework middleware (e.g., Django) after exporting a full span
# via sdk.collect_span(). Tells TdSpanProcessor.on_end() to skip the span
# so it doesn't produce a duplicate empty root span.
EXPORTED_BY_INSTRUMENTATION = "td.exported_by_instrumentation"

# Replay mode
REPLAY_TRACE_ID = "td.replay_trace_id"
13 changes: 13 additions & 0 deletions drift/core/tracing/td_span_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ def on_end(self, span: ReadableSpan) -> None:
)
return

# Skip spans already exported by framework middleware (e.g., Django _capture_span).
# Those middlewares create a full CleanSpanData with HTTP body data and export it
# directly via sdk.collect_span(). Processing them again here would produce a
# duplicate root span with empty inputValue/outputValue.
attributes = dict(span.attributes) if span.attributes else {}
from .td_attributes import TdSpanAttributes

if attributes.get(TdSpanAttributes.EXPORTED_BY_INSTRUMENTATION):
logger.debug(
f"[TdSpanProcessor] Skipping span '{span.name}' - already exported by instrumentation"
)
return

try:
# Convert OTel span to CleanSpanData
logger.debug(f"[TdSpanProcessor] Converting span '{span.name}' to CleanSpanData")
Expand Down
8 changes: 8 additions & 0 deletions drift/instrumentation/django/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ def dict_to_schema_merges(merges_dict):

sdk.collect_span(clean_span)

# Mark OTel span so TdSpanProcessor.on_end() skips it - we already
# exported the full span with HTTP body data above.
span_info.span.set_attribute(TdSpanAttributes.EXPORTED_BY_INSTRUMENTATION, True)

def _capture_error_span(self, request: HttpRequest, exception: Exception, span_info: SpanInfo) -> None:
"""Create and collect an error span.

Expand Down Expand Up @@ -553,3 +557,7 @@ def dict_to_schema_merges(merges_dict):
)

sdk.collect_span(clean_span)

# Mark OTel span so TdSpanProcessor.on_end() skips it - we already
# exported the full error span above.
span_info.span.set_attribute(TdSpanAttributes.EXPORTED_BY_INSTRUMENTATION, True)
69 changes: 69 additions & 0 deletions tests/unit/test_td_span_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,75 @@ def test_processes_drift_span_in_record_mode(self, mocker):

processor._batch_processor.add_span.assert_called_once_with(mock_clean_span)

def test_skips_span_already_exported_by_instrumentation(self, mocker):
"""Should skip spans with EXPORTED_BY_INSTRUMENTATION attribute set.

Framework middlewares (e.g., Django _capture_span) export a full
CleanSpanData with HTTP body data via sdk.collect_span() and then
set this attribute. Processing the span again in on_end() would
produce a duplicate empty root span.
"""
from drift.core.tracing.td_attributes import TdSpanAttributes

mock_exporter = mocker.MagicMock()
processor = TdSpanProcessor(
exporter=mock_exporter,
mode=TuskDriftMode.RECORD,
)
processor._started = True
processor._batch_processor = mocker.MagicMock()

mock_span = self._create_mock_span(mocker)
mock_span.attributes = {
TdSpanAttributes.EXPORTED_BY_INSTRUMENTATION: True,
}

mock_converter = mocker.patch("drift.core.tracing.td_span_processor.otel_span_to_clean_span_data")

processor.on_end(mock_span)

# Should not convert or add to batch — span was already exported
mock_converter.assert_not_called()
processor._batch_processor.add_span.assert_not_called()

def test_processes_span_without_exported_flag(self, mocker):
"""Should process spans that don't have EXPORTED_BY_INSTRUMENTATION set.

Child spans (redis, psycopg2, etc.) and framework spans that use
the OTel-only export path should still be processed normally.
"""
mock_exporter = mocker.MagicMock()
processor = TdSpanProcessor(
exporter=mock_exporter,
mode=TuskDriftMode.RECORD,
)
processor._started = True
processor._batch_processor = mocker.MagicMock()

mock_blocking = mocker.patch("drift.core.tracing.td_span_processor.TraceBlockingManager")
mock_blocking_instance = mocker.MagicMock()
mock_blocking_instance.is_trace_blocked.return_value = False
mock_blocking.get_instance.return_value = mock_blocking_instance

mock_converter = mocker.patch("drift.core.tracing.td_span_processor.otel_span_to_clean_span_data")
mock_clean_span = mocker.MagicMock()
mock_clean_span.trace_id = "a" * 32
mock_clean_span.name = "redis.MGET"
mock_clean_span.kind = SpanKind.CLIENT
mock_clean_span.status.code = StatusCode.OK
mock_clean_span.to_proto.return_value = mocker.MagicMock()
mock_converter.return_value = mock_clean_span

mock_span = self._create_mock_span(mocker, name="redis.MGET")
# No EXPORTED_BY_INSTRUMENTATION attribute — should process normally
mock_span.attributes = {}

mocker.patch("drift.core.tracing.td_span_processor.should_block_span", return_value=False)
processor.on_end(mock_span)

mock_converter.assert_called_once()
processor._batch_processor.add_span.assert_called_once_with(mock_clean_span)

def test_skips_blocked_trace(self, mocker):
"""Should skip processing when trace is blocked."""
mock_exporter = mocker.MagicMock()
Expand Down
Loading