Skip to content

Commit 7c99402

Browse files
fix: inbound replay span not sent to CLI in Django/FastAPI REPLAY mode
1 parent 6b92f39 commit 7c99402

4 files changed

Lines changed: 65 additions & 7 deletions

File tree

.cursor/BUGBOT.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,15 @@
33
## Instrumentation Guidelines
44

55
- When adding a new instrumentation, the README must be updated to document the new instrumentation.
6+
7+
## REPLAY Mode: Context Ordering and OUTPUT_VALUE on Inbound Spans
8+
9+
When implementing or modifying a REPLAY mode handler (e.g., `_handle_replay_request`) in any framework instrumentation (Django, FastAPI, Flask, WSGI, etc.):
10+
11+
1. **`span.end()` MUST be called BEFORE `replay_trace_id_context.reset()`.**
12+
`TdSpanProcessor.on_end()` is triggered synchronously by `span.end()` and reads `replay_trace_id_context.get()` to route the inbound replay span to the CLI. If the context is reset first, the processor silently drops the span and the CLI never receives it.
13+
14+
2. **`OUTPUT_VALUE` MUST be set on the server span before `span.end()` in REPLAY mode.**
15+
The CLI determines pass/fail using the raw HTTP response it receives directly, but the backend/UI uses the `OUTPUT_VALUE` from the inbound replay span to populate `span_result_recording` for the "Expected vs Actual" diff view. If OUTPUT_VALUE is missing, the UI shows expected data on the left and empty `{}` on the right — appearing as a deviation even though the test passed.
16+
17+
3. **E2E tests will NOT catch these bugs** because they only check the CLI's `passed` boolean, which is based on the raw HTTP response comparison — not the inbound replay span data.

drift/instrumentation/django/e2e-tests/.tusk/config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,3 @@ recording:
2525

2626
replay:
2727
enable_telemetry: false
28-

drift/instrumentation/django/middleware.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,15 +144,19 @@ def _handle_replay_request(self, request: HttpRequest, sdk) -> HttpResponse:
144144
try:
145145
with SpanUtils.with_span(span_info):
146146
response = self.get_response(request)
147-
# REPLAY mode: don't capture the span (it's already recorded)
148-
# But do normalize the response so comparison succeeds
149147
response = self._normalize_html_response(response)
148+
149+
# Capture response data on the span so the inbound replay span
150+
# sent to the CLI includes the actual OUTPUT_VALUE for UI display
151+
self._capture_replay_output(request, response, span_info)
152+
150153
return response
151154
finally:
152-
# Reset context
155+
# End span BEFORE resetting context so that TdSpanProcessor.on_end()
156+
# can still read replay_trace_id_context to send the inbound span
157+
span_info.span.end()
153158
span_kind_context.reset(span_kind_token)
154159
replay_trace_id_context.reset(replay_token)
155-
span_info.span.end()
156160

157161
def _record_request(self, request: HttpRequest, sdk, is_pre_app_start: bool) -> HttpResponse:
158162
"""Handle request in RECORD mode.
@@ -262,6 +266,48 @@ def process_view(
262266
if route:
263267
request._drift_route_template = route # type: ignore
264268

269+
def _capture_replay_output(self, request: HttpRequest, response: HttpResponse, span_info: SpanInfo) -> None:
270+
"""Capture response data on the span for REPLAY mode.
271+
272+
Sets OUTPUT_VALUE so the inbound replay span sent to the CLI includes
273+
the actual response for UI comparison. Skips RECORD-mode concerns like
274+
transforms, trace blocking, and schema merges.
275+
276+
Args:
277+
request: Django HttpRequest object
278+
response: Django HttpResponse object
279+
span_info: SpanInfo containing trace/span IDs and span reference
280+
"""
281+
if not span_info.span.is_recording():
282+
return
283+
284+
status_code = response.status_code
285+
status_message = response.reason_phrase if hasattr(response, "reason_phrase") else ""
286+
response_headers = dict(response.items()) if hasattr(response, "items") else {}
287+
288+
response_body = None
289+
if hasattr(response, "content"):
290+
content = response.content
291+
if isinstance(content, bytes) and len(content) > 0:
292+
response_body = content
293+
294+
if response_body:
295+
from .html_utils import normalize_html_body
296+
297+
content_type = response_headers.get("Content-Type", "")
298+
content_encoding = response_headers.get("Content-Encoding", "")
299+
response_body = normalize_html_body(response_body, content_type, content_encoding)
300+
301+
output_value = build_output_value(
302+
status_code,
303+
status_message,
304+
response_headers,
305+
response_body,
306+
None,
307+
)
308+
309+
span_info.span.set_attribute(TdSpanAttributes.OUTPUT_VALUE, json.dumps(output_value))
310+
265311
def _normalize_html_response(self, response: HttpResponse) -> HttpResponse:
266312
"""Normalize HTML response body for REPLAY mode comparison.
267313

drift/instrumentation/fastapi/instrumentation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,11 @@ async def wrapped_send(message: dict[str, Any]) -> None:
226226
transform_engine,
227227
)
228228
finally:
229-
# Reset context
229+
# End span BEFORE resetting context so that TdSpanProcessor.on_end()
230+
# can still read replay_trace_id_context to send the inbound span
231+
span_info.span.end()
230232
span_kind_context.reset(span_kind_token)
231233
replay_trace_id_context.reset(replay_token)
232-
span_info.span.end()
233234

234235

235236
async def _record_request(

0 commit comments

Comments
 (0)