Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
2 changes: 0 additions & 2 deletions drift/core/drift_sdk.py
Original file line number Diff line number Diff line change
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
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
21 changes: 18 additions & 3 deletions drift/instrumentation/socket/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,34 @@ def patch(self, module: ModuleType) -> None:
logger.warning("[SocketInstrumentation] socket.socket class not found")
return

import socket as _socket_module

SOCK_STREAM = _socket_module.SOCK_STREAM

# Store original methods exactly like the working test pattern
original_connect = socket_class.connect
original_send = socket_class.send
original_sendall = socket_class.sendall

instrumentation = self

def _is_tcp(sock: Any) -> bool:
"""Only TCP (SOCK_STREAM) sockets carry HTTP traffic.

UDP sockets (StatsD, DNS, etc.) are fire-and-forget side-effects
that don't need record/replay instrumentation.
"""
try:
return (sock.type & SOCK_STREAM) != 0
except Exception:
return False

# Patch connect - always track and detect
def patched_connect(self: Any, *args: Any, **kwargs: Any) -> Any:
"""Patched socket.connect method."""
# Track this socket as an outbound socket
instrumentation._outbound_sockets.add(self)
instrumentation._handle_socket_call("connect", self)
if _is_tcp(self):
instrumentation._outbound_sockets.add(self)
instrumentation._handle_socket_call("connect", self)
return original_connect(self, *args, **kwargs)

# Patch send - only detect if socket is tracked (outbound)
Expand Down
18 changes: 11 additions & 7 deletions drift/instrumentation/urllib/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,18 @@ def patched_open(opener_self, fullurl, data=None, timeout=_GLOBAL_DEFAULT_TIMEOU
if sdk.mode == TuskDriftMode.DISABLED:
return original_open(opener_self, fullurl, data, timeout)

# Set calling_library_context to suppress socket instrumentation warnings
# context_token = calling_library_context.set("urllib")
# Extract URL early so we can check the scheme
if isinstance(fullurl, str):
url = fullurl
else:
url = fullurl.full_url

# Only instrument HTTP/HTTPS requests; pass through file://, data://, ftp://, etc.
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return original_open(opener_self, fullurl, data, timeout)

try:
# Extract URL for default response handler
if isinstance(fullurl, str):
url = fullurl
else:
url = fullurl.full_url

def original_call():
return original_open(opener_self, fullurl, data, timeout)
Expand Down
1 change: 0 additions & 1 deletion drift/instrumentation/urllib3/e2e-tests/.tusk/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,3 @@ recording:

replay:
enable_telemetry: false

Loading