Skip to content

Commit abc5a0c

Browse files
fix: Django setup timing, auto app-ready, and urllib3 botocore support (#81)
1 parent b19d9e6 commit abc5a0c

File tree

22 files changed

+619
-182
lines changed

22 files changed

+619
-182
lines changed

E2E_TESTING_GUIDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ pkill -f "python src/app.py"
143143
Run the Tusk CLI to replay the recorded traces:
144144

145145
```bash
146-
TUSK_ANALYTICS_DISABLED=1 tusk run --print --output-format "json" --enable-service-logs
146+
TUSK_ANALYTICS_DISABLED=1 tusk drift run --print --output-format "json" --enable-service-logs
147147
```
148148

149149
**Flags explained:**
@@ -155,7 +155,7 @@ TUSK_ANALYTICS_DISABLED=1 tusk run --print --output-format "json" --enable-servi
155155
To see all available flags, run:
156156

157157
```bash
158-
tusk run --help
158+
tusk drift run --help
159159
```
160160

161161
**Interpreting Results:**
@@ -235,7 +235,7 @@ The actual test orchestration happens inside the container via `entrypoint.py`,
235235
2. Starts app in RECORD mode
236236
3. Executes test requests
237237
4. Stops app, verifies traces
238-
5. Runs `tusk run` CLI
238+
5. Runs `tusk drift run` CLI
239239
6. Checks for socket instrumentation warnings
240240
7. Returns exit code
241241

@@ -334,7 +334,7 @@ TUSK_DRIFT_MODE=RECORD python src/app.py
334334
python src/test_requests.py
335335
336336
# Inside container: Run Tusk CLI tests
337-
TUSK_ANALYTICS_DISABLED=1 tusk run --print --output-format "json" --enable-service-logs
337+
TUSK_ANALYTICS_DISABLED=1 tusk drift run --print --output-format "json" --enable-service-logs
338338
339339
# View traces
340340
cat .tusk/traces/*.jsonl | python -m json.tool

drift/core/drift_sdk.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ def _init_auto_instrumentations(self) -> None:
443443
pass
444444

445445
try:
446-
import httpx # type: ignore[unresolved-import]
446+
import httpx
447447

448448
from ..instrumentation.httpx import HttpxInstrumentation
449449

@@ -473,7 +473,7 @@ def _init_auto_instrumentations(self) -> None:
473473
pass
474474

475475
try:
476-
import sqlalchemy # type: ignore[unresolved-import]
476+
import sqlalchemy
477477

478478
from ..instrumentation.sqlalchemy import SqlAlchemyInstrumentation
479479

@@ -490,7 +490,7 @@ def _init_auto_instrumentations(self) -> None:
490490

491491
# Try psycopg2 first
492492
try:
493-
import psycopg2 # type: ignore[unresolved-import]
493+
import psycopg2
494494

495495
from ..instrumentation.psycopg2 import Psycopg2Instrumentation
496496

@@ -502,7 +502,7 @@ def _init_auto_instrumentations(self) -> None:
502502

503503
# Try psycopg (v3)
504504
try:
505-
import psycopg # type: ignore[unresolved-import]
505+
import psycopg
506506

507507
from ..instrumentation.psycopg import PsycopgInstrumentation
508508

@@ -518,7 +518,7 @@ def _init_auto_instrumentations(self) -> None:
518518
logger.debug("Both psycopg2 and psycopg available - instrumented both")
519519

520520
try:
521-
import redis # type: ignore[unresolved-import]
521+
import redis
522522

523523
from ..instrumentation.redis import RedisInstrumentation
524524

@@ -528,7 +528,7 @@ def _init_auto_instrumentations(self) -> None:
528528
pass
529529

530530
try:
531-
import grpc # type: ignore[unresolved-import]
531+
import grpc
532532

533533
from ..instrumentation.grpc import GrpcInstrumentation
534534

@@ -646,8 +646,6 @@ def mark_app_as_ready(self) -> None:
646646
if self._td_span_processor:
647647
self._td_span_processor.update_app_ready(True)
648648

649-
logger.debug("Application marked as ready")
650-
651649
if self.mode == TuskDriftMode.REPLAY:
652650
logger.debug("Replay mode active - ready to serve mocked responses")
653651
elif self.mode == TuskDriftMode.RECORD:

drift/instrumentation/django/instrumentation.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,31 +49,42 @@ def _resolve_http_transforms(
4949
@override
5050
def patch(self, module: ModuleType) -> None:
5151
"""Patch Django by injecting middleware."""
52+
if not self._try_inject_middleware():
53+
# Settings not configured yet — defer injection until django.setup() runs
54+
self._defer_middleware_injection()
55+
56+
def _try_inject_middleware(self) -> bool:
57+
"""Attempt to inject DriftMiddleware into Django settings.
58+
59+
Returns:
60+
True if middleware was injected (or already present), False if
61+
settings are not yet configured and injection should be deferred.
62+
"""
5263
global _middleware_injected
5364

5465
if _middleware_injected:
5566
logger.debug("Middleware already injected, skipping")
56-
return
67+
return True
5768

5869
try:
5970
from django.conf import settings
6071

6172
if not settings.configured:
62-
logger.warning("Django settings not configured, cannot inject middleware")
63-
return
73+
logger.debug("Django settings not configured yet, will defer middleware injection")
74+
return False
6475

6576
middleware_setting = self._get_middleware_setting(settings)
6677
if not middleware_setting:
6778
logger.warning("Could not find middleware setting, cannot inject")
68-
return
79+
return True # Don't retry — this won't change
6980

7081
current_middleware = list(getattr(settings, middleware_setting, []))
7182

7283
middleware_path = "drift.instrumentation.django.middleware.DriftMiddleware"
7384
if middleware_path in current_middleware:
7485
logger.debug("DriftMiddleware already in settings, skipping injection")
7586
_middleware_injected = True
76-
return
87+
return True
7788

7889
# Insert at position 0 to capture all requests
7990
current_middleware.insert(0, middleware_path)
@@ -89,11 +100,38 @@ def patch(self, module: ModuleType) -> None:
89100
self._force_database_reconnect()
90101

91102
print("Django instrumentation applied")
103+
return True
92104

93105
except ImportError as e:
94106
logger.warning(f"Could not import Django settings: {e}")
107+
return True # Don't retry on import errors
95108
except Exception as e:
96109
logger.error(f"Failed to inject middleware: {e}", exc_info=True)
110+
return True # Don't retry on unexpected errors
111+
112+
def _defer_middleware_injection(self) -> None:
113+
"""Monkey-patch django.setup() to inject middleware after settings are configured.
114+
115+
When TuskDrift.initialize() runs before DJANGO_SETTINGS_MODULE is set
116+
(common in manage.py where the SDK init is the first import), Django
117+
settings aren't available yet. This defers injection to run after
118+
django.setup() completes, which is when settings are guaranteed to be
119+
configured.
120+
"""
121+
import django
122+
123+
original_setup = django.setup
124+
125+
def patched_setup(*args, **kwargs):
126+
try:
127+
result = original_setup(*args, **kwargs)
128+
self._try_inject_middleware()
129+
return result
130+
finally:
131+
django.setup = original_setup
132+
133+
django.setup = patched_setup # ty: ignore[invalid-assignment]
134+
logger.debug("Deferred middleware injection to django.setup()")
97135

98136
def _force_database_reconnect(self) -> None:
99137
"""Force Django to close and recreate database connections."""

drift/instrumentation/django/middleware.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
6666
if sdk.mode == TuskDriftMode.DISABLED:
6767
return self.get_response(request)
6868

69+
if not sdk.app_ready:
70+
sdk.mark_app_as_ready()
71+
6972
# REPLAY mode - handle trace ID extraction and context setup
7073
if sdk.mode == TuskDriftMode.REPLAY:
7174
return self._handle_replay_request(request, sdk)

drift/instrumentation/e2e_common/mock_upstream/mock_server.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import gzip
67
import json
78
import os
89
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -19,6 +20,17 @@ def _json(handler: BaseHTTPRequestHandler, payload: Any, status: int = 200):
1920
handler.wfile.write(body)
2021

2122

23+
def _json_gzip(handler: BaseHTTPRequestHandler, payload: Any, status: int = 200):
24+
"""Serve JSON compressed with gzip, setting Content-Encoding: gzip."""
25+
body = gzip.compress(json.dumps(payload).encode("utf-8"))
26+
handler.send_response(status)
27+
handler.send_header("Content-Type", "application/json")
28+
handler.send_header("Content-Encoding", "gzip")
29+
handler.send_header("Content-Length", str(len(body)))
30+
handler.end_headers()
31+
handler.wfile.write(body)
32+
33+
2234
def _text(handler: BaseHTTPRequestHandler, payload: str, status: int = 200):
2335
body = payload.encode("utf-8")
2436
handler.send_response(status)
@@ -147,6 +159,12 @@ def do_GET(self):
147159
},
148160
)
149161

162+
if path == "/gzip":
163+
return _json_gzip(
164+
self,
165+
{"gzipped": True, "method": "GET", "origin": "mock"},
166+
)
167+
150168
if path in {"/json", "/json/"}:
151169
return _json(
152170
self,

drift/instrumentation/fastapi/instrumentation.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,9 @@ async def _handle_request(
398398
if sdk.mode == TuskDriftMode.DISABLED:
399399
return await original_call(app, scope, receive, send)
400400

401+
if not sdk.app_ready:
402+
sdk.mark_app_as_ready()
403+
401404
# REPLAY mode - handle trace ID extraction and context setup
402405
if sdk.mode == TuskDriftMode.REPLAY:
403406
return await _handle_replay_request(

drift/instrumentation/psycopg/e2e-tests/src/app.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -957,12 +957,16 @@ def test_decimal_types():
957957

958958
@app.route("/test/date-time-types")
959959
def test_date_time_types():
960-
"""Test date/time types."""
960+
"""Test date/time types are preserved as proper Python objects during replay.
961+
962+
Verifies that DATE columns come back as datetime.date, TIME columns as
963+
datetime.time, and INTERVAL columns as datetime.timedelta — not plain strings.
964+
Also exercises datetime.combine() which fails if date/time are strings.
965+
"""
961966
try:
962-
from datetime import date, time, timedelta
967+
from datetime import date, datetime, time, timedelta
963968

964969
with psycopg.connect(get_conn_string()) as conn, conn.cursor() as cur:
965-
# Create temp table with date/time columns
966970
cur.execute("""
967971
CREATE TEMP TABLE datetime_test (
968972
id INT,
@@ -972,23 +976,41 @@ def test_date_time_types():
972976
)
973977
""")
974978

975-
# Insert date/time data
976979
cur.execute(
977980
"INSERT INTO datetime_test VALUES (%s, %s, %s, %s)",
978981
(1, date(1990, 5, 15), time(8, 30, 0), timedelta(hours=2, minutes=30)),
979982
)
980983

981-
# Query back
982984
cur.execute("SELECT * FROM datetime_test WHERE id = 1")
983985
row = cur.fetchone()
984986
conn.commit()
985987

988+
birth_date = row[1]
989+
wake_time = row[2]
990+
duration = row[3]
991+
992+
type_checks = {
993+
"birth_date_is_date": isinstance(birth_date, date) and not isinstance(birth_date, datetime),
994+
"wake_time_is_time": isinstance(wake_time, time),
995+
"duration_is_timedelta": isinstance(duration, timedelta),
996+
}
997+
998+
# Exercise datetime.combine() — this is the exact operation that fails
999+
# when date/time values are returned as strings during replay
1000+
combined = datetime.combine(birth_date, wake_time)
1001+
type_checks["combine_works"] = isinstance(combined, datetime)
1002+
type_checks["combine_value"] = combined.isoformat()
1003+
1004+
all_types_correct = all(v for k, v in type_checks.items() if k != "combine_value")
1005+
9861006
return jsonify(
9871007
{
9881008
"id": row[0],
989-
"birth_date": str(row[1]) if row[1] else None,
990-
"wake_time": str(row[2]) if row[2] else None,
991-
"duration": str(row[3]) if row[3] else None,
1009+
"birth_date": str(birth_date),
1010+
"wake_time": str(wake_time),
1011+
"duration": str(duration),
1012+
"type_checks": type_checks,
1013+
"all_types_correct": all_types_correct,
9921014
}
9931015
)
9941016
except Exception as e:

drift/instrumentation/psycopg/instrumentation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from ..base import InstrumentationBase
2727
from ..sqlalchemy.context import sqlalchemy_execution_active_context, sqlalchemy_replay_mock_context
28-
from ..utils.psycopg_utils import deserialize_db_value, restore_row_integer_types
28+
from ..utils.psycopg_utils import deserialize_db_value, restore_row_date_types, restore_row_integer_types
2929
from ..utils.serialization import serialize_value
3030
from .mocks import MockConnection, MockCopy
3131
from .wrappers import TracedCopyWrapper
@@ -1829,6 +1829,7 @@ def _mock_execute_with_data(self, cursor: Any, mock_data: dict[str, Any], is_asy
18291829
# Deserialize datetime strings back to datetime objects for consistent Flask serialization
18301830
mock_rows = [deserialize_db_value(row) for row in mock_rows]
18311831
mock_rows = [restore_row_integer_types(row, description_data) for row in mock_rows]
1832+
mock_rows = [restore_row_date_types(row, description_data) for row in mock_rows]
18321833
cursor._mock_rows = mock_rows # pyright: ignore[reportAttributeAccessIssue]
18331834
cursor._mock_index = 0 # pyright: ignore[reportAttributeAccessIssue]
18341835

@@ -1898,6 +1899,7 @@ def _mock_executemany_returning_with_data(self, cursor: Any, mock_data: dict[str
18981899
mock_rows = result_set.get("rows", [])
18991900
mock_rows = [deserialize_db_value(row) for row in mock_rows]
19001901
mock_rows = [restore_row_integer_types(row, description_data) for row in mock_rows]
1902+
mock_rows = [restore_row_date_types(row, description_data) for row in mock_rows]
19011903

19021904
cursor._mock_result_sets.append( # pyright: ignore[reportAttributeAccessIssue]
19031905
{

0 commit comments

Comments
 (0)