timetracer 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- timetracer/__init__.py +29 -0
- timetracer/cassette/__init__.py +6 -0
- timetracer/cassette/io.py +421 -0
- timetracer/cassette/naming.py +69 -0
- timetracer/catalog/__init__.py +288 -0
- timetracer/cli/__init__.py +5 -0
- timetracer/cli/commands/__init__.py +1 -0
- timetracer/cli/main.py +692 -0
- timetracer/config.py +297 -0
- timetracer/constants.py +129 -0
- timetracer/context.py +93 -0
- timetracer/dashboard/__init__.py +14 -0
- timetracer/dashboard/generator.py +229 -0
- timetracer/dashboard/server.py +244 -0
- timetracer/dashboard/template.py +874 -0
- timetracer/diff/__init__.py +6 -0
- timetracer/diff/engine.py +311 -0
- timetracer/diff/report.py +113 -0
- timetracer/exceptions.py +113 -0
- timetracer/integrations/__init__.py +27 -0
- timetracer/integrations/fastapi.py +537 -0
- timetracer/integrations/flask.py +507 -0
- timetracer/plugins/__init__.py +42 -0
- timetracer/plugins/base.py +73 -0
- timetracer/plugins/httpx_plugin.py +413 -0
- timetracer/plugins/redis_plugin.py +297 -0
- timetracer/plugins/requests_plugin.py +333 -0
- timetracer/plugins/sqlalchemy_plugin.py +280 -0
- timetracer/policies/__init__.py +16 -0
- timetracer/policies/capture.py +64 -0
- timetracer/policies/redaction.py +165 -0
- timetracer/replay/__init__.py +6 -0
- timetracer/replay/engine.py +75 -0
- timetracer/replay/errors.py +9 -0
- timetracer/replay/matching.py +83 -0
- timetracer/session.py +390 -0
- timetracer/storage/__init__.py +18 -0
- timetracer/storage/s3.py +364 -0
- timetracer/timeline/__init__.py +6 -0
- timetracer/timeline/generator.py +150 -0
- timetracer/timeline/template.py +370 -0
- timetracer/types.py +197 -0
- timetracer/utils/__init__.py +6 -0
- timetracer/utils/hashing.py +68 -0
- timetracer/utils/time.py +106 -0
- timetracer-1.1.0.dist-info/METADATA +286 -0
- timetracer-1.1.0.dist-info/RECORD +51 -0
- timetracer-1.1.0.dist-info/WHEEL +5 -0
- timetracer-1.1.0.dist-info/entry_points.txt +2 -0
- timetracer-1.1.0.dist-info/licenses/LICENSE +21 -0
- timetracer-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redaction policies for sensitive data.
|
|
3
|
+
|
|
4
|
+
Removes or masks sensitive headers and body content before storage.
|
|
5
|
+
Uses centralized constants for consistency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from timetracer.constants import ALLOWED_HEADERS, Redaction
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def redact_headers(
|
|
17
|
+
headers: dict[str, str],
|
|
18
|
+
*,
|
|
19
|
+
mode: str = "drop",
|
|
20
|
+
additional_sensitive: set[str] | None = None,
|
|
21
|
+
) -> dict[str, str]:
|
|
22
|
+
"""
|
|
23
|
+
Redact sensitive headers.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
headers: Original headers dict.
|
|
27
|
+
mode: "drop" to remove sensitive headers, "mask" to replace values.
|
|
28
|
+
additional_sensitive: Additional header names to treat as sensitive.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
New dict with sensitive headers removed or masked.
|
|
32
|
+
"""
|
|
33
|
+
sensitive = Redaction.SENSITIVE_HEADERS
|
|
34
|
+
if additional_sensitive:
|
|
35
|
+
sensitive = sensitive | {h.lower() for h in additional_sensitive}
|
|
36
|
+
|
|
37
|
+
result = {}
|
|
38
|
+
for key, value in headers.items():
|
|
39
|
+
key_lower = key.lower()
|
|
40
|
+
|
|
41
|
+
if key_lower in sensitive:
|
|
42
|
+
if mode == "mask":
|
|
43
|
+
result[key] = Redaction.REDACTED_VALUE
|
|
44
|
+
# else: drop (don't include)
|
|
45
|
+
else:
|
|
46
|
+
result[key] = value
|
|
47
|
+
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def redact_headers_allowlist(
|
|
52
|
+
headers: dict[str, str],
|
|
53
|
+
allowed: frozenset[str] | None = None,
|
|
54
|
+
) -> dict[str, str]:
|
|
55
|
+
"""
|
|
56
|
+
Keep only allowed headers (allowlist approach).
|
|
57
|
+
|
|
58
|
+
This is safer than blocklist - only explicitly allowed headers are kept.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
headers: Original headers dict.
|
|
62
|
+
allowed: Set of allowed header names (lowercase). Defaults to ALLOWED_HEADERS.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
New dict with only allowed headers.
|
|
66
|
+
"""
|
|
67
|
+
if allowed is None:
|
|
68
|
+
allowed = ALLOWED_HEADERS
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
key: value
|
|
72
|
+
for key, value in headers.items()
|
|
73
|
+
if key.lower() in allowed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def redact_body(
|
|
78
|
+
body: Any,
|
|
79
|
+
*,
|
|
80
|
+
additional_sensitive_keys: set[str] | None = None,
|
|
81
|
+
) -> Any:
|
|
82
|
+
"""
|
|
83
|
+
Redact sensitive keys in a body object.
|
|
84
|
+
|
|
85
|
+
Recursively processes dicts and lists.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
body: The body data (usually a dict or list).
|
|
89
|
+
additional_sensitive_keys: Additional keys to redact.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
New object with sensitive values masked.
|
|
93
|
+
"""
|
|
94
|
+
if body is None:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
sensitive_keys = Redaction.SENSITIVE_BODY_KEYS
|
|
98
|
+
if additional_sensitive_keys:
|
|
99
|
+
sensitive_keys = sensitive_keys | {k.lower() for k in additional_sensitive_keys}
|
|
100
|
+
|
|
101
|
+
return _redact_recursive(body, sensitive_keys)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact_recursive(obj: Any, sensitive_keys: frozenset[str]) -> Any:
|
|
105
|
+
"""Recursively redact sensitive keys."""
|
|
106
|
+
if isinstance(obj, dict):
|
|
107
|
+
result = {}
|
|
108
|
+
for key, value in obj.items():
|
|
109
|
+
if _is_sensitive_key(key, sensitive_keys):
|
|
110
|
+
result[key] = Redaction.REDACTED_VALUE
|
|
111
|
+
else:
|
|
112
|
+
result[key] = _redact_recursive(value, sensitive_keys)
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
elif isinstance(obj, list):
|
|
116
|
+
return [_redact_recursive(item, sensitive_keys) for item in obj]
|
|
117
|
+
|
|
118
|
+
elif isinstance(obj, str):
|
|
119
|
+
# Optionally mask token-like strings
|
|
120
|
+
return _mask_token_like(obj)
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
return obj
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_sensitive_key(key: str, sensitive_keys: frozenset[str]) -> bool:
|
|
127
|
+
"""Check if a key is sensitive (case-insensitive substring match)."""
|
|
128
|
+
key_lower = key.lower()
|
|
129
|
+
|
|
130
|
+
# Exact match
|
|
131
|
+
if key_lower in sensitive_keys:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# Substring match for compound keys
|
|
135
|
+
for sensitive in sensitive_keys:
|
|
136
|
+
if sensitive in key_lower:
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _mask_token_like(value: str) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Mask token-like strings in values.
|
|
145
|
+
|
|
146
|
+
Patterns:
|
|
147
|
+
- JWT tokens (eyJ...)
|
|
148
|
+
- Bearer tokens
|
|
149
|
+
- API keys (long alphanumeric strings)
|
|
150
|
+
"""
|
|
151
|
+
# JWT pattern
|
|
152
|
+
if value.startswith("eyJ") and value.count(".") == 2:
|
|
153
|
+
return Redaction.REDACTED_VALUE
|
|
154
|
+
|
|
155
|
+
# Bearer prefix
|
|
156
|
+
if value.lower().startswith("bearer "):
|
|
157
|
+
return f"Bearer {Redaction.REDACTED_VALUE}"
|
|
158
|
+
|
|
159
|
+
# Very long alphanumeric strings (likely API keys)
|
|
160
|
+
if len(value) > 32 and re.match(r"^[a-zA-Z0-9_-]+$", value):
|
|
161
|
+
# Only mask if it looks random (has mixed case or underscores)
|
|
162
|
+
if re.search(r"[a-z]", value) and re.search(r"[A-Z0-9_-]", value):
|
|
163
|
+
return Redaction.REDACTED_VALUE
|
|
164
|
+
|
|
165
|
+
return value
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Replay engine for Timetracer.
|
|
3
|
+
|
|
4
|
+
The ReplayEngine manages cassette playback and event matching.
|
|
5
|
+
Most functionality is in ReplaySession (session.py), this provides
|
|
6
|
+
additional utilities.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from timetracer.cassette import read_cassette
|
|
14
|
+
from timetracer.constants import EventType
|
|
15
|
+
from timetracer.session import ReplaySession
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from timetracer.types import Cassette, DependencyEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReplayEngine:
|
|
22
|
+
"""
|
|
23
|
+
Engine for replaying cassettes.
|
|
24
|
+
|
|
25
|
+
This is a convenience wrapper around ReplaySession for programmatic use.
|
|
26
|
+
Most applications will use ReplaySession directly via the middleware.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
cassette_path: str,
|
|
32
|
+
strict: bool = True,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Initialize replay engine.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
cassette_path: Path to the cassette file.
|
|
39
|
+
strict: If True, raise on mismatches.
|
|
40
|
+
"""
|
|
41
|
+
self.cassette_path = cassette_path
|
|
42
|
+
self.strict = strict
|
|
43
|
+
self._cassette: Cassette | None = None
|
|
44
|
+
self._session: ReplaySession | None = None
|
|
45
|
+
|
|
46
|
+
def load(self) -> None:
|
|
47
|
+
"""Load the cassette from disk."""
|
|
48
|
+
self._cassette = read_cassette(self.cassette_path)
|
|
49
|
+
self._session = ReplaySession(
|
|
50
|
+
cassette=self._cassette,
|
|
51
|
+
cassette_path=self.cassette_path,
|
|
52
|
+
strict=self.strict,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def cassette(self) -> Cassette:
|
|
57
|
+
"""Get loaded cassette."""
|
|
58
|
+
if self._cassette is None:
|
|
59
|
+
raise RuntimeError("Cassette not loaded. Call load() first.")
|
|
60
|
+
return self._cassette
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def session(self) -> ReplaySession:
|
|
64
|
+
"""Get replay session."""
|
|
65
|
+
if self._session is None:
|
|
66
|
+
raise RuntimeError("Session not created. Call load() first.")
|
|
67
|
+
return self._session
|
|
68
|
+
|
|
69
|
+
def get_events_by_type(self, event_type: EventType) -> list[DependencyEvent]:
|
|
70
|
+
"""Get all events of a specific type."""
|
|
71
|
+
return [e for e in self.cassette.events if e.event_type == event_type]
|
|
72
|
+
|
|
73
|
+
def get_http_events(self) -> list[DependencyEvent]:
|
|
74
|
+
"""Get all HTTP client events."""
|
|
75
|
+
return self.get_events_by_type(EventType.HTTP_CLIENT)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signature matching for replay.
|
|
3
|
+
|
|
4
|
+
Provides utilities for comparing recorded and actual call signatures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
|
|
12
|
+
from timetracer.types import EventSignature
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_url(url: str) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Normalize a URL for comparison.
|
|
18
|
+
|
|
19
|
+
Removes query string, normalizes scheme/host/path.
|
|
20
|
+
"""
|
|
21
|
+
parsed = urlparse(url)
|
|
22
|
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_query(query_string: str) -> dict[str, Any]:
|
|
26
|
+
"""Normalize query string to sorted dict."""
|
|
27
|
+
parsed = parse_qs(query_string)
|
|
28
|
+
# Flatten single-value lists
|
|
29
|
+
return {k: v[0] if len(v) == 1 else sorted(v) for k, v in sorted(parsed.items())}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def signatures_match(
|
|
33
|
+
expected: EventSignature,
|
|
34
|
+
actual: dict[str, Any],
|
|
35
|
+
*,
|
|
36
|
+
check_body_hash: bool = False,
|
|
37
|
+
) -> tuple[bool, list[str]]:
|
|
38
|
+
"""
|
|
39
|
+
Check if two signatures match.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
expected: The recorded signature.
|
|
43
|
+
actual: The actual call signature dict.
|
|
44
|
+
check_body_hash: Whether to compare body hashes.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple of (matches, list of mismatch reasons).
|
|
48
|
+
"""
|
|
49
|
+
mismatches: list[str] = []
|
|
50
|
+
|
|
51
|
+
# Check method
|
|
52
|
+
if expected.method != actual.get("method"):
|
|
53
|
+
mismatches.append(
|
|
54
|
+
f"method: expected {expected.method}, got {actual.get('method')}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Check URL
|
|
58
|
+
expected_url = normalize_url(expected.url) if expected.url else None
|
|
59
|
+
actual_url = normalize_url(actual.get("url", "")) if actual.get("url") else None
|
|
60
|
+
|
|
61
|
+
if expected_url != actual_url:
|
|
62
|
+
mismatches.append(
|
|
63
|
+
f"url: expected {expected_url}, got {actual_url}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Check body hash (optional)
|
|
67
|
+
if check_body_hash and expected.body_hash:
|
|
68
|
+
if expected.body_hash != actual.get("body_hash"):
|
|
69
|
+
mismatches.append(
|
|
70
|
+
f"body_hash: expected {expected.body_hash[:20]}..., got {actual.get('body_hash', 'none')[:20]}..."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return len(mismatches) == 0, mismatches
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_signature_summary(sig: EventSignature) -> str:
|
|
77
|
+
"""Create a human-readable summary of a signature."""
|
|
78
|
+
parts = [sig.method]
|
|
79
|
+
if sig.url:
|
|
80
|
+
parts.append(sig.url)
|
|
81
|
+
if sig.query:
|
|
82
|
+
parts.append(f"?{len(sig.query)} params")
|
|
83
|
+
return " ".join(parts)
|