Skip to content
Merged
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
8 changes: 4 additions & 4 deletions E2E_TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ pkill -f "python src/app.py"
Run the Tusk CLI to replay the recorded traces:

```bash
TUSK_ANALYTICS_DISABLED=1 tusk run --print --output-format "json" --enable-service-logs
TUSK_ANALYTICS_DISABLED=1 tusk drift run --print --output-format "json" --enable-service-logs
```

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

```bash
tusk run --help
tusk drift run --help
```

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

Expand Down Expand Up @@ -334,7 +334,7 @@ TUSK_DRIFT_MODE=RECORD python src/app.py
python src/test_requests.py

# Inside container: Run Tusk CLI tests
TUSK_ANALYTICS_DISABLED=1 tusk run --print --output-format "json" --enable-service-logs
TUSK_ANALYTICS_DISABLED=1 tusk drift run --print --output-format "json" --enable-service-logs

# View traces
cat .tusk/traces/*.jsonl | python -m json.tool
Expand Down
14 changes: 6 additions & 8 deletions drift/core/drift_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ def _init_auto_instrumentations(self) -> None:
pass

try:
import httpx # type: ignore[unresolved-import]
import httpx

from ..instrumentation.httpx import HttpxInstrumentation

Expand Down Expand Up @@ -473,7 +473,7 @@ def _init_auto_instrumentations(self) -> None:
pass

try:
import sqlalchemy # type: ignore[unresolved-import]
import sqlalchemy

from ..instrumentation.sqlalchemy import SqlAlchemyInstrumentation

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

# Try psycopg2 first
try:
import psycopg2 # type: ignore[unresolved-import]
import psycopg2

from ..instrumentation.psycopg2 import Psycopg2Instrumentation

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

# Try psycopg (v3)
try:
import psycopg # type: ignore[unresolved-import]
import psycopg

from ..instrumentation.psycopg import PsycopgInstrumentation

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

try:
import redis # type: ignore[unresolved-import]
import redis

from ..instrumentation.redis import RedisInstrumentation

Expand All @@ -528,7 +528,7 @@ def _init_auto_instrumentations(self) -> None:
pass

try:
import grpc # type: ignore[unresolved-import]
import grpc

from ..instrumentation.grpc import GrpcInstrumentation

Expand Down Expand Up @@ -646,8 +646,6 @@ def mark_app_as_ready(self) -> None:
if self._td_span_processor:
self._td_span_processor.update_app_ready(True)

logger.debug("Application marked as ready")

if self.mode == TuskDriftMode.REPLAY:
logger.debug("Replay mode active - ready to serve mocked responses")
elif self.mode == TuskDriftMode.RECORD:
Expand Down
48 changes: 43 additions & 5 deletions drift/instrumentation/django/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,31 +49,42 @@ def _resolve_http_transforms(
@override
def patch(self, module: ModuleType) -> None:
"""Patch Django by injecting middleware."""
if not self._try_inject_middleware():
# Settings not configured yet — defer injection until django.setup() runs
self._defer_middleware_injection()

def _try_inject_middleware(self) -> bool:
"""Attempt to inject DriftMiddleware into Django settings.

Returns:
True if middleware was injected (or already present), False if
settings are not yet configured and injection should be deferred.
"""
global _middleware_injected

if _middleware_injected:
logger.debug("Middleware already injected, skipping")
return
return True

try:
from django.conf import settings

if not settings.configured:
logger.warning("Django settings not configured, cannot inject middleware")
return
logger.debug("Django settings not configured yet, will defer middleware injection")
return False

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

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

middleware_path = "drift.instrumentation.django.middleware.DriftMiddleware"
if middleware_path in current_middleware:
logger.debug("DriftMiddleware already in settings, skipping injection")
_middleware_injected = True
return
return True

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

print("Django instrumentation applied")
return True

except ImportError as e:
logger.warning(f"Could not import Django settings: {e}")
return True # Don't retry on import errors
except Exception as e:
logger.error(f"Failed to inject middleware: {e}", exc_info=True)
return True # Don't retry on unexpected errors

def _defer_middleware_injection(self) -> None:
"""Monkey-patch django.setup() to inject middleware after settings are configured.

When TuskDrift.initialize() runs before DJANGO_SETTINGS_MODULE is set
(common in manage.py where the SDK init is the first import), Django
settings aren't available yet. This defers injection to run after
django.setup() completes, which is when settings are guaranteed to be
configured.
"""
import django

original_setup = django.setup

def patched_setup(*args, **kwargs):
try:
result = original_setup(*args, **kwargs)
self._try_inject_middleware()
return result
finally:
django.setup = original_setup

django.setup = patched_setup # ty: ignore[invalid-assignment]
logger.debug("Deferred middleware injection to django.setup()")

def _force_database_reconnect(self) -> None:
"""Force Django to close and recreate database connections."""
Expand Down
3 changes: 3 additions & 0 deletions drift/instrumentation/django/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
if sdk.mode == TuskDriftMode.DISABLED:
return self.get_response(request)

if not sdk.app_ready:
sdk.mark_app_as_ready()

# REPLAY mode - handle trace ID extraction and context setup
if sdk.mode == TuskDriftMode.REPLAY:
return self._handle_replay_request(request, sdk)
Expand Down
18 changes: 18 additions & 0 deletions drift/instrumentation/e2e_common/mock_upstream/mock_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

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


def _json_gzip(handler: BaseHTTPRequestHandler, payload: Any, status: int = 200):
"""Serve JSON compressed with gzip, setting Content-Encoding: gzip."""
body = gzip.compress(json.dumps(payload).encode("utf-8"))
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Encoding", "gzip")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)


def _text(handler: BaseHTTPRequestHandler, payload: str, status: int = 200):
body = payload.encode("utf-8")
handler.send_response(status)
Expand Down Expand Up @@ -147,6 +159,12 @@ def do_GET(self):
},
)

if path == "/gzip":
return _json_gzip(
self,
{"gzipped": True, "method": "GET", "origin": "mock"},
)

if path in {"/json", "/json/"}:
return _json(
self,
Expand Down
3 changes: 3 additions & 0 deletions drift/instrumentation/fastapi/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,9 @@ async def _handle_request(
if sdk.mode == TuskDriftMode.DISABLED:
return await original_call(app, scope, receive, send)

if not sdk.app_ready:
sdk.mark_app_as_ready()

# REPLAY mode - handle trace ID extraction and context setup
if sdk.mode == TuskDriftMode.REPLAY:
return await _handle_replay_request(
Expand Down
38 changes: 30 additions & 8 deletions drift/instrumentation/psycopg/e2e-tests/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -957,12 +957,16 @@ def test_decimal_types():

@app.route("/test/date-time-types")
def test_date_time_types():
"""Test date/time types."""
"""Test date/time types are preserved as proper Python objects during replay.

Verifies that DATE columns come back as datetime.date, TIME columns as
datetime.time, and INTERVAL columns as datetime.timedelta — not plain strings.
Also exercises datetime.combine() which fails if date/time are strings.
"""
try:
from datetime import date, time, timedelta
from datetime import date, datetime, time, timedelta

with psycopg.connect(get_conn_string()) as conn, conn.cursor() as cur:
# Create temp table with date/time columns
cur.execute("""
CREATE TEMP TABLE datetime_test (
id INT,
Expand All @@ -972,23 +976,41 @@ def test_date_time_types():
)
""")

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

# Query back
cur.execute("SELECT * FROM datetime_test WHERE id = 1")
row = cur.fetchone()
conn.commit()

birth_date = row[1]
wake_time = row[2]
duration = row[3]

type_checks = {
"birth_date_is_date": isinstance(birth_date, date) and not isinstance(birth_date, datetime),
"wake_time_is_time": isinstance(wake_time, time),
"duration_is_timedelta": isinstance(duration, timedelta),
}

# Exercise datetime.combine() — this is the exact operation that fails
# when date/time values are returned as strings during replay
combined = datetime.combine(birth_date, wake_time)
type_checks["combine_works"] = isinstance(combined, datetime)
type_checks["combine_value"] = combined.isoformat()

all_types_correct = all(v for k, v in type_checks.items() if k != "combine_value")

return jsonify(
{
"id": row[0],
"birth_date": str(row[1]) if row[1] else None,
"wake_time": str(row[2]) if row[2] else None,
"duration": str(row[3]) if row[3] else None,
"birth_date": str(birth_date),
"wake_time": str(wake_time),
"duration": str(duration),
"type_checks": type_checks,
"all_types_correct": all_types_correct,
}
)
except Exception as e:
Expand Down
4 changes: 3 additions & 1 deletion drift/instrumentation/psycopg/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
)
from ..base import InstrumentationBase
from ..sqlalchemy.context import sqlalchemy_execution_active_context, sqlalchemy_replay_mock_context
from ..utils.psycopg_utils import deserialize_db_value, restore_row_integer_types
from ..utils.psycopg_utils import deserialize_db_value, restore_row_date_types, restore_row_integer_types
from ..utils.serialization import serialize_value
from .mocks import MockConnection, MockCopy
from .wrappers import TracedCopyWrapper
Expand Down Expand Up @@ -1829,6 +1829,7 @@ def _mock_execute_with_data(self, cursor: Any, mock_data: dict[str, Any], is_asy
# Deserialize datetime strings back to datetime objects for consistent Flask serialization
mock_rows = [deserialize_db_value(row) for row in mock_rows]
mock_rows = [restore_row_integer_types(row, description_data) for row in mock_rows]
mock_rows = [restore_row_date_types(row, description_data) for row in mock_rows]
cursor._mock_rows = mock_rows # pyright: ignore[reportAttributeAccessIssue]
cursor._mock_index = 0 # pyright: ignore[reportAttributeAccessIssue]

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

cursor._mock_result_sets.append( # pyright: ignore[reportAttributeAccessIssue]
{
Expand Down
Loading
Loading