Skip to content

Commit ba09fa6

Browse files
pyJWT + urllib instrumentations (#43)
1 parent c2246fa commit ba09fa6

23 files changed

+2355
-13
lines changed

.cursor/BUGBOT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# BUGBOT Notes
2+
3+
## Instrumentation Guidelines
4+
5+
- When adding a new instrumentation, the README must be updated to document the new instrumentation.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Tusk Drift currently supports the following packages and versions:
5757
| psycopg2 | all versions |
5858
| Redis | `>=4.0.0` |
5959
| Kinde | `>=2.0.1` |
60+
| PyJWT | all versions |
61+
| urllib.request | all versions |
6062

6163
If you're using packages or versions not listed above, please create an issue with the package + version you'd like an instrumentation for.
6264

drift/core/content_type_utils.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"application/vnd.api+json": DecodedType.JSON,
1818
# Plain Text (ALLOWED)
1919
"text/plain": DecodedType.PLAIN_TEXT,
20-
# HTML (BLOCKED)
20+
# HTML
2121
"text/html": DecodedType.HTML,
2222
"application/xhtml+xml": DecodedType.HTML,
2323
# CSS (BLOCKED)
@@ -111,9 +111,7 @@
111111
"application/binary": DecodedType.BINARY,
112112
}
113113

114-
# Only JSON and plain text are acceptable (matches Node SDK)
115-
# All other content types will cause trace blocking
116-
ACCEPTABLE_DECODED_TYPES = {DecodedType.JSON, DecodedType.PLAIN_TEXT}
114+
ACCEPTABLE_DECODED_TYPES = {DecodedType.JSON, DecodedType.PLAIN_TEXT, DecodedType.HTML}
117115

118116

119117
def get_decoded_type(content_type: str | None) -> DecodedType | None:

drift/core/drift_sdk.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,16 @@ def _init_auto_instrumentations(self) -> None:
406406
except ImportError:
407407
pass
408408

409+
try:
410+
import urllib.request
411+
412+
from ..instrumentation.urllib import UrllibInstrumentation
413+
414+
_ = UrllibInstrumentation()
415+
logger.debug("urllib instrumentation initialized")
416+
except ImportError:
417+
pass
418+
409419
# Initialize PostgreSQL instrumentation before Django
410420
# Instrument BOTH psycopg2 and psycopg if available
411421
# This allows apps to use either or both
@@ -491,6 +501,15 @@ def _init_auto_instrumentations(self) -> None:
491501
except Exception as e:
492502
logger.debug(f"Socket instrumentation initialization failed: {e}")
493503

504+
# PyJWT instrumentation for JWT verification bypass
505+
try:
506+
from ..instrumentation.pyjwt import PyJWTInstrumentation
507+
508+
_ = PyJWTInstrumentation(mode=self.mode)
509+
logger.debug("PyJWT instrumentation registered (REPLAY mode)")
510+
except Exception as e:
511+
logger.debug(f"PyJWT instrumentation registration failed: {e}")
512+
494513
def create_env_vars_snapshot(self) -> None:
495514
"""Create a span capturing all environment variables.
496515

drift/core/mock_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,9 @@ def find_mock_response_sync(
183183
mock_response = sdk.request_mock_sync(mock_request)
184184

185185
if not mock_response or not mock_response.found:
186-
logger.debug(f"No matching mock found for {trace_id} with input value: {input_value}")
186+
logger.debug(
187+
f"No matching mock found for {trace_id} with input value: {input_value}, input schema: {input_schema_merges}, input schema hash: {outbound_span.input_schema_hash}, input value hash: {outbound_span.input_value_hash}"
188+
)
187189
return None
188190

189191
logger.debug(f"Found mock response for {trace_id}")

drift/core/trace_blocking_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def should_block_span(span: CleanSpanData) -> bool:
204204
"""Check if a span should be blocked due to size or server error status.
205205
206206
Blocks the trace if:
207-
1. The span is a SERVER span with ERROR status (e.g., HTTP >= 300)
207+
1. The span is a SERVER span with ERROR status (e.g., HTTP >= 400)
208208
2. The span exceeds the maximum size limit (1MB)
209209
210210
This matches Node SDK behavior in TdSpanExporter.ts.
@@ -221,7 +221,7 @@ def should_block_span(span: CleanSpanData) -> bool:
221221
span_name = span.name
222222
blocking_manager = TraceBlockingManager.get_instance()
223223

224-
# Check 1: Block SERVER spans with ERROR status (e.g., HTTP >= 300)
224+
# Check 1: Block SERVER spans with ERROR status (e.g., HTTP >= 400)
225225
if span.kind == SpanKind.SERVER and span.status.code == StatusCode.ERROR:
226226
logger.debug(f"Blocking trace {trace_id} - server span '{span_name}' has error status")
227227
blocking_manager.block_trace(trace_id, reason="server_error")

drift/instrumentation/django/middleware.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,8 @@ def dict_to_schema_merges(merges_dict):
381381
duration_seconds = duration_ns // 1_000_000_000
382382
duration_nanos = duration_ns % 1_000_000_000
383383

384-
# Match Node SDK: >= 300 is considered an error (redirects, client errors, server errors)
385-
if status_code >= 300:
384+
# Match Node SDK: >= 400 is considered an error
385+
if status_code >= 400:
386386
status = SpanStatus(code=StatusCode.ERROR, message=f"HTTP {status_code}")
387387
else:
388388
status = SpanStatus(code=StatusCode.OK, message="")

drift/instrumentation/fastapi/instrumentation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,8 @@ def _finalize_span(
535535
TuskDrift.get_instance()
536536

537537
status_code = response_data.get("status_code", 200)
538-
# Match Node SDK: >= 300 is considered an error (redirects, client errors, server errors)
539-
if status_code >= 300:
538+
# Match Node SDK: >= 400 is considered an error
539+
if status_code >= 400:
540540
span_info.span.set_status(Status(OTelStatusCode.ERROR, f"HTTP {status_code}"))
541541
else:
542542
span_info.span.set_status(Status(OTelStatusCode.OK))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""PyJWT instrumentation for REPLAY mode."""
2+
3+
from .instrumentation import PyJWTInstrumentation
4+
5+
__all__ = ["PyJWTInstrumentation"]
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""PyJWT instrumentation for REPLAY mode.
2+
3+
Patches PyJWT to disable all verification during test replay:
4+
1. _merge_options - returns all verification options as False
5+
2. _verify_signature - no-op (defense in depth)
6+
3. _validate_claims - no-op (defense in depth)
7+
8+
Only active in REPLAY mode.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import logging
14+
from types import ModuleType
15+
16+
from ...core.types import TuskDriftMode
17+
from ..base import InstrumentationBase
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class PyJWTInstrumentation(InstrumentationBase):
23+
"""Patches PyJWT to disable verification in REPLAY mode."""
24+
25+
def __init__(self, mode: TuskDriftMode = TuskDriftMode.DISABLED, enabled: bool = True) -> None:
26+
self.mode = mode
27+
should_enable = enabled and mode == TuskDriftMode.REPLAY
28+
29+
super().__init__(
30+
name="PyJWTInstrumentation",
31+
module_name="jwt",
32+
supported_versions="*",
33+
enabled=should_enable,
34+
)
35+
36+
def patch(self, module: ModuleType) -> None:
37+
if self.mode != TuskDriftMode.REPLAY:
38+
return
39+
40+
self._patch_merge_options()
41+
self._patch_signature_verification()
42+
self._patch_claim_validation()
43+
logger.debug("[PyJWTInstrumentation] All patches applied")
44+
45+
def _patch_signature_verification(self) -> None:
46+
"""No-op signature verification."""
47+
try:
48+
from jwt import api_jws
49+
50+
def patched_verify_signature(self, *args, **kwargs):
51+
logger.debug("[PyJWTInstrumentation] _verify_signature called - skipping verification")
52+
return None
53+
54+
api_jws.PyJWS._verify_signature = patched_verify_signature
55+
logger.debug("[PyJWTInstrumentation] Patched PyJWS._verify_signature")
56+
except Exception as e:
57+
logger.warning(f"[PyJWTInstrumentation] Failed to patch _verify_signature: {e}")
58+
59+
def _patch_claim_validation(self) -> None:
60+
"""No-op claim validation."""
61+
try:
62+
from jwt import api_jwt
63+
64+
def patched_validate_claims(self, *args, **kwargs):
65+
logger.debug("[PyJWTInstrumentation] _validate_claims called - skipping validation")
66+
return None
67+
68+
api_jwt.PyJWT._validate_claims = patched_validate_claims
69+
logger.debug("[PyJWTInstrumentation] Patched PyJWT._validate_claims")
70+
except Exception as e:
71+
logger.warning(f"[PyJWTInstrumentation] Failed to patch _validate_claims: {e}")
72+
73+
def _patch_merge_options(self) -> None:
74+
"""Patch _merge_options to always return disabled verification options."""
75+
try:
76+
from jwt import api_jwt
77+
78+
disabled_options = {
79+
"verify_signature": False,
80+
"verify_exp": False,
81+
"verify_nbf": False,
82+
"verify_iat": False,
83+
"verify_aud": False,
84+
"verify_iss": False,
85+
"verify_sub": False,
86+
"verify_jti": False,
87+
"require": [],
88+
"strict_aud": False,
89+
}
90+
91+
def patched_merge_options(self, options=None):
92+
logger.debug("[PyJWTInstrumentation] _merge_options called - returning disabled options")
93+
return disabled_options
94+
95+
api_jwt.PyJWT._merge_options = patched_merge_options
96+
logger.debug("[PyJWTInstrumentation] Patched PyJWT._merge_options")
97+
except Exception as e:
98+
logger.warning(f"[PyJWTInstrumentation] Failed to patch _merge_options: {e}")

0 commit comments

Comments
 (0)