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
timetracer/config.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TraceConfig - Central configuration for Timetracer.
|
|
3
|
+
|
|
4
|
+
This is the main configuration object that controls all behavior.
|
|
5
|
+
It can be created programmatically or loaded from environment variables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from timetracer.constants import (
|
|
15
|
+
CapturePolicy,
|
|
16
|
+
Defaults,
|
|
17
|
+
EnvVars,
|
|
18
|
+
TraceMode,
|
|
19
|
+
)
|
|
20
|
+
from timetracer.exceptions import ConfigurationError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_bool(value: str) -> bool:
|
|
24
|
+
"""Parse a boolean from environment variable."""
|
|
25
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_csv(value: str) -> list[str]:
|
|
29
|
+
"""Parse a comma-separated list from environment variable."""
|
|
30
|
+
if not value.strip():
|
|
31
|
+
return []
|
|
32
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TraceConfig:
|
|
37
|
+
"""
|
|
38
|
+
Configuration for Timetracer.
|
|
39
|
+
|
|
40
|
+
This is the single source of truth for all runtime configuration.
|
|
41
|
+
Create via constructor or use from_env() to load from environment.
|
|
42
|
+
|
|
43
|
+
Priority order:
|
|
44
|
+
1. Explicit constructor arguments
|
|
45
|
+
2. Environment variables (via from_env())
|
|
46
|
+
3. Default values
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
# Explicit configuration
|
|
50
|
+
cfg = TraceConfig(mode=TraceMode.RECORD, cassette_dir="./recordings")
|
|
51
|
+
|
|
52
|
+
# Load from environment
|
|
53
|
+
cfg = TraceConfig.from_env()
|
|
54
|
+
|
|
55
|
+
# Mixed: defaults + env overrides
|
|
56
|
+
cfg = TraceConfig(service_name="my-api").with_env_overrides()
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Core settings
|
|
60
|
+
mode: TraceMode = Defaults.MODE
|
|
61
|
+
service_name: str = Defaults.SERVICE_NAME
|
|
62
|
+
env: str = Defaults.ENV
|
|
63
|
+
|
|
64
|
+
# Cassette storage
|
|
65
|
+
cassette_dir: str = Defaults.CASSETTE_DIR
|
|
66
|
+
cassette_path: str | None = None # Specific cassette for replay
|
|
67
|
+
|
|
68
|
+
# Capture control
|
|
69
|
+
capture: list[str] = field(default_factory=lambda: ["http"])
|
|
70
|
+
sample_rate: float = Defaults.SAMPLE_RATE
|
|
71
|
+
errors_only: bool = Defaults.ERRORS_ONLY
|
|
72
|
+
exclude_paths: list[str] = field(default_factory=lambda: list(Defaults.EXCLUDE_PATHS))
|
|
73
|
+
|
|
74
|
+
# Body capture policies
|
|
75
|
+
max_body_kb: int = Defaults.MAX_BODY_KB
|
|
76
|
+
store_request_body: CapturePolicy = Defaults.STORE_REQUEST_BODY
|
|
77
|
+
store_response_body: CapturePolicy = Defaults.STORE_RESPONSE_BODY
|
|
78
|
+
|
|
79
|
+
# Replay settings
|
|
80
|
+
strict_replay: bool = Defaults.STRICT_REPLAY
|
|
81
|
+
|
|
82
|
+
# Hybrid replay - selective mocking
|
|
83
|
+
# If mock_plugins is empty, all plugins are mocked (default behavior)
|
|
84
|
+
# If mock_plugins is set, only those plugins are mocked
|
|
85
|
+
# Plugins in live_plugins are never mocked (kept live)
|
|
86
|
+
mock_plugins: list[str] = field(default_factory=list)
|
|
87
|
+
live_plugins: list[str] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
# Logging
|
|
90
|
+
log_level: str = Defaults.LOG_LEVEL
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
"""Validate configuration after initialization."""
|
|
94
|
+
# Convert string mode to enum if needed
|
|
95
|
+
if isinstance(self.mode, str):
|
|
96
|
+
try:
|
|
97
|
+
self.mode = TraceMode(self.mode.lower())
|
|
98
|
+
except ValueError:
|
|
99
|
+
raise ConfigurationError(
|
|
100
|
+
f"Invalid mode: {self.mode}. Must be one of: {[m.value for m in TraceMode]}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Convert string policies to enum if needed
|
|
104
|
+
if isinstance(self.store_request_body, str):
|
|
105
|
+
try:
|
|
106
|
+
self.store_request_body = CapturePolicy(self.store_request_body.lower())
|
|
107
|
+
except ValueError:
|
|
108
|
+
raise ConfigurationError(
|
|
109
|
+
f"Invalid store_request_body: {self.store_request_body}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if isinstance(self.store_response_body, str):
|
|
113
|
+
try:
|
|
114
|
+
self.store_response_body = CapturePolicy(self.store_response_body.lower())
|
|
115
|
+
except ValueError:
|
|
116
|
+
raise ConfigurationError(
|
|
117
|
+
f"Invalid store_response_body: {self.store_response_body}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Validate sample_rate
|
|
121
|
+
if not 0.0 <= self.sample_rate <= 1.0:
|
|
122
|
+
raise ConfigurationError(
|
|
123
|
+
f"sample_rate must be between 0.0 and 1.0, got {self.sample_rate}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Validate max_body_kb
|
|
127
|
+
if self.max_body_kb < 0:
|
|
128
|
+
raise ConfigurationError(
|
|
129
|
+
f"max_body_kb must be non-negative, got {self.max_body_kb}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_env(cls) -> TraceConfig:
|
|
134
|
+
"""
|
|
135
|
+
Create configuration from environment variables.
|
|
136
|
+
|
|
137
|
+
All TIMETRACER_* environment variables are read and used.
|
|
138
|
+
Missing variables use defaults.
|
|
139
|
+
"""
|
|
140
|
+
kwargs: dict = {}
|
|
141
|
+
|
|
142
|
+
# Mode
|
|
143
|
+
if mode := os.environ.get(EnvVars.MODE):
|
|
144
|
+
kwargs["mode"] = mode
|
|
145
|
+
|
|
146
|
+
# Service/env
|
|
147
|
+
if service := os.environ.get(EnvVars.SERVICE):
|
|
148
|
+
kwargs["service_name"] = service
|
|
149
|
+
if env := os.environ.get(EnvVars.ENV):
|
|
150
|
+
kwargs["env"] = env
|
|
151
|
+
|
|
152
|
+
# Cassette paths
|
|
153
|
+
if cassette_dir := os.environ.get(EnvVars.DIR):
|
|
154
|
+
kwargs["cassette_dir"] = cassette_dir
|
|
155
|
+
if cassette_path := os.environ.get(EnvVars.CASSETTE):
|
|
156
|
+
kwargs["cassette_path"] = cassette_path
|
|
157
|
+
|
|
158
|
+
# Capture settings
|
|
159
|
+
if capture := os.environ.get(EnvVars.CAPTURE):
|
|
160
|
+
kwargs["capture"] = _parse_csv(capture)
|
|
161
|
+
if sample_rate := os.environ.get(EnvVars.SAMPLE_RATE):
|
|
162
|
+
try:
|
|
163
|
+
kwargs["sample_rate"] = float(sample_rate)
|
|
164
|
+
except ValueError:
|
|
165
|
+
raise ConfigurationError(f"Invalid TIMETRACER_SAMPLE_RATE: {sample_rate}")
|
|
166
|
+
if errors_only := os.environ.get(EnvVars.ERRORS_ONLY):
|
|
167
|
+
kwargs["errors_only"] = _parse_bool(errors_only)
|
|
168
|
+
if exclude_paths := os.environ.get(EnvVars.EXCLUDE_PATHS):
|
|
169
|
+
kwargs["exclude_paths"] = _parse_csv(exclude_paths)
|
|
170
|
+
|
|
171
|
+
# Body policies
|
|
172
|
+
if max_body := os.environ.get(EnvVars.MAX_BODY_KB):
|
|
173
|
+
try:
|
|
174
|
+
kwargs["max_body_kb"] = int(max_body)
|
|
175
|
+
except ValueError:
|
|
176
|
+
raise ConfigurationError(f"Invalid TIMETRACER_MAX_BODY_KB: {max_body}")
|
|
177
|
+
if store_req := os.environ.get(EnvVars.STORE_REQ_BODY):
|
|
178
|
+
kwargs["store_request_body"] = store_req
|
|
179
|
+
if store_res := os.environ.get(EnvVars.STORE_RES_BODY):
|
|
180
|
+
kwargs["store_response_body"] = store_res
|
|
181
|
+
|
|
182
|
+
# Replay
|
|
183
|
+
if strict := os.environ.get(EnvVars.STRICT_REPLAY):
|
|
184
|
+
kwargs["strict_replay"] = _parse_bool(strict)
|
|
185
|
+
|
|
186
|
+
# Logging
|
|
187
|
+
if log_level := os.environ.get(EnvVars.LOG_LEVEL):
|
|
188
|
+
kwargs["log_level"] = log_level.lower()
|
|
189
|
+
|
|
190
|
+
# Hybrid replay
|
|
191
|
+
if mock_plugins := os.environ.get(EnvVars.MOCK_PLUGINS):
|
|
192
|
+
kwargs["mock_plugins"] = _parse_csv(mock_plugins)
|
|
193
|
+
if live_plugins := os.environ.get(EnvVars.LIVE_PLUGINS):
|
|
194
|
+
kwargs["live_plugins"] = _parse_csv(live_plugins)
|
|
195
|
+
|
|
196
|
+
return cls(**kwargs)
|
|
197
|
+
|
|
198
|
+
def with_env_overrides(self) -> TraceConfig:
|
|
199
|
+
"""
|
|
200
|
+
Return a new config with environment variables applied as overrides.
|
|
201
|
+
|
|
202
|
+
This allows setting base config in code and overriding via env.
|
|
203
|
+
"""
|
|
204
|
+
env_config = TraceConfig.from_env()
|
|
205
|
+
|
|
206
|
+
# Create new config, preferring env values over current where set
|
|
207
|
+
return TraceConfig(
|
|
208
|
+
mode=env_config.mode if os.environ.get(EnvVars.MODE) else self.mode,
|
|
209
|
+
service_name=env_config.service_name if os.environ.get(EnvVars.SERVICE) else self.service_name,
|
|
210
|
+
env=env_config.env if os.environ.get(EnvVars.ENV) else self.env,
|
|
211
|
+
cassette_dir=env_config.cassette_dir if os.environ.get(EnvVars.DIR) else self.cassette_dir,
|
|
212
|
+
cassette_path=env_config.cassette_path if os.environ.get(EnvVars.CASSETTE) else self.cassette_path,
|
|
213
|
+
capture=env_config.capture if os.environ.get(EnvVars.CAPTURE) else self.capture,
|
|
214
|
+
sample_rate=env_config.sample_rate if os.environ.get(EnvVars.SAMPLE_RATE) else self.sample_rate,
|
|
215
|
+
errors_only=env_config.errors_only if os.environ.get(EnvVars.ERRORS_ONLY) else self.errors_only,
|
|
216
|
+
exclude_paths=env_config.exclude_paths if os.environ.get(EnvVars.EXCLUDE_PATHS) else self.exclude_paths,
|
|
217
|
+
max_body_kb=env_config.max_body_kb if os.environ.get(EnvVars.MAX_BODY_KB) else self.max_body_kb,
|
|
218
|
+
store_request_body=env_config.store_request_body if os.environ.get(EnvVars.STORE_REQ_BODY) else self.store_request_body,
|
|
219
|
+
store_response_body=env_config.store_response_body if os.environ.get(EnvVars.STORE_RES_BODY) else self.store_response_body,
|
|
220
|
+
strict_replay=env_config.strict_replay if os.environ.get(EnvVars.STRICT_REPLAY) else self.strict_replay,
|
|
221
|
+
log_level=env_config.log_level if os.environ.get(EnvVars.LOG_LEVEL) else self.log_level,
|
|
222
|
+
mock_plugins=env_config.mock_plugins if os.environ.get(EnvVars.MOCK_PLUGINS) else self.mock_plugins,
|
|
223
|
+
live_plugins=env_config.live_plugins if os.environ.get(EnvVars.LIVE_PLUGINS) else self.live_plugins,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def should_trace(self, path: str) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Determine if a request path should be traced.
|
|
229
|
+
|
|
230
|
+
Returns False for excluded paths.
|
|
231
|
+
"""
|
|
232
|
+
# Normalize path
|
|
233
|
+
path = path.split("?")[0] # Remove query string
|
|
234
|
+
|
|
235
|
+
# Check exclusions
|
|
236
|
+
for excluded in self.exclude_paths:
|
|
237
|
+
if path == excluded or path.startswith(excluded + "/"):
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def should_sample(self) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Determine if this request should be sampled for recording.
|
|
245
|
+
|
|
246
|
+
Uses sample_rate for probabilistic sampling.
|
|
247
|
+
"""
|
|
248
|
+
if self.sample_rate >= 1.0:
|
|
249
|
+
return True
|
|
250
|
+
if self.sample_rate <= 0.0:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
import random
|
|
254
|
+
return random.random() < self.sample_rate
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def is_record_mode(self) -> bool:
|
|
258
|
+
"""Check if in record mode."""
|
|
259
|
+
return self.mode == TraceMode.RECORD
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def is_replay_mode(self) -> bool:
|
|
263
|
+
"""Check if in replay mode."""
|
|
264
|
+
return self.mode == TraceMode.REPLAY
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def is_enabled(self) -> bool:
|
|
268
|
+
"""Check if timetrace is enabled (not OFF)."""
|
|
269
|
+
return self.mode != TraceMode.OFF
|
|
270
|
+
|
|
271
|
+
def get_python_version(self) -> str:
|
|
272
|
+
"""Get current Python version string."""
|
|
273
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
274
|
+
|
|
275
|
+
def should_mock_plugin(self, plugin_name: str) -> bool:
|
|
276
|
+
"""
|
|
277
|
+
Determine if a plugin should be mocked during replay.
|
|
278
|
+
|
|
279
|
+
This enables hybrid replay where some dependencies are mocked
|
|
280
|
+
and others are kept live (e.g., mock Stripe but keep DB live).
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
plugin_name: The name of the plugin (e.g., "http", "db", "redis")
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True if the plugin should be mocked, False if it should stay live.
|
|
287
|
+
"""
|
|
288
|
+
# Live plugins are never mocked
|
|
289
|
+
if self.live_plugins and plugin_name in self.live_plugins:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
# If mock_plugins is specified, only those are mocked
|
|
293
|
+
if self.mock_plugins:
|
|
294
|
+
return plugin_name in self.mock_plugins
|
|
295
|
+
|
|
296
|
+
# Default: mock everything
|
|
297
|
+
return True
|
timetracer/constants.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized constants for Timetracer.
|
|
3
|
+
|
|
4
|
+
All magic strings, default values, and configuration constants live here.
|
|
5
|
+
This ensures consistency and makes future changes easy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
# =============================================================================
|
|
12
|
+
# SCHEMA VERSION - bump this when cassette format changes
|
|
13
|
+
# =============================================================================
|
|
14
|
+
SCHEMA_VERSION: Final[str] = "1.0"
|
|
15
|
+
SUPPORTED_SCHEMA_VERSIONS: Final[tuple[str, ...]] = ("0.1", "1.0")
|
|
16
|
+
|
|
17
|
+
# =============================================================================
|
|
18
|
+
# MODE CONSTANTS
|
|
19
|
+
# =============================================================================
|
|
20
|
+
class TraceMode(str, Enum):
|
|
21
|
+
"""Operating mode for Timetracer."""
|
|
22
|
+
OFF = "off"
|
|
23
|
+
RECORD = "record"
|
|
24
|
+
REPLAY = "replay"
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# BODY CAPTURE POLICY
|
|
28
|
+
# =============================================================================
|
|
29
|
+
class CapturePolicy(str, Enum):
|
|
30
|
+
"""When to capture request/response bodies."""
|
|
31
|
+
NEVER = "never"
|
|
32
|
+
ON_ERROR = "on_error"
|
|
33
|
+
ALWAYS = "always"
|
|
34
|
+
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# EVENT TYPES - centralized so plugins use consistent naming
|
|
37
|
+
# =============================================================================
|
|
38
|
+
class EventType(str, Enum):
|
|
39
|
+
"""Types of dependency events that can be captured."""
|
|
40
|
+
HTTP_CLIENT = "http.client"
|
|
41
|
+
DB_QUERY = "db.query"
|
|
42
|
+
REDIS = "redis"
|
|
43
|
+
CUSTOM = "custom"
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# DEFAULT VALUES - single source of truth
|
|
47
|
+
# =============================================================================
|
|
48
|
+
class Defaults:
|
|
49
|
+
"""Default configuration values."""
|
|
50
|
+
MODE: TraceMode = TraceMode.OFF
|
|
51
|
+
SERVICE_NAME: str = "timetracer-service"
|
|
52
|
+
ENV: str = "local"
|
|
53
|
+
CASSETTE_DIR: str = "./cassettes"
|
|
54
|
+
SAMPLE_RATE: float = 1.0
|
|
55
|
+
ERRORS_ONLY: bool = False
|
|
56
|
+
MAX_BODY_KB: int = 64
|
|
57
|
+
STORE_REQUEST_BODY: CapturePolicy = CapturePolicy.ON_ERROR
|
|
58
|
+
STORE_RESPONSE_BODY: CapturePolicy = CapturePolicy.ON_ERROR
|
|
59
|
+
STRICT_REPLAY: bool = True
|
|
60
|
+
LOG_LEVEL: str = "info"
|
|
61
|
+
EXCLUDE_PATHS: tuple[str, ...] = ("/health", "/metrics", "/docs", "/openapi.json")
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# REDACTION CONSTANTS - headers to always remove
|
|
65
|
+
# =============================================================================
|
|
66
|
+
class Redaction:
|
|
67
|
+
"""Redaction rules."""
|
|
68
|
+
# Headers that are ALWAYS removed (case-insensitive matching)
|
|
69
|
+
SENSITIVE_HEADERS: frozenset[str] = frozenset({
|
|
70
|
+
"authorization",
|
|
71
|
+
"cookie",
|
|
72
|
+
"set-cookie",
|
|
73
|
+
"x-api-key",
|
|
74
|
+
"x-auth-token",
|
|
75
|
+
"x-access-token",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
# Body keys that should be masked (case-insensitive substring matching)
|
|
79
|
+
SENSITIVE_BODY_KEYS: frozenset[str] = frozenset({
|
|
80
|
+
"password",
|
|
81
|
+
"secret",
|
|
82
|
+
"token",
|
|
83
|
+
"api_key",
|
|
84
|
+
"apikey",
|
|
85
|
+
"access_token",
|
|
86
|
+
"refresh_token",
|
|
87
|
+
"private_key",
|
|
88
|
+
"credit_card",
|
|
89
|
+
"ssn",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
# Replacement for redacted values
|
|
93
|
+
REDACTED_VALUE: str = "[REDACTED]"
|
|
94
|
+
|
|
95
|
+
# =============================================================================
|
|
96
|
+
# ENVIRONMENT VARIABLE NAMES - consistent prefix
|
|
97
|
+
# =============================================================================
|
|
98
|
+
class EnvVars:
|
|
99
|
+
"""Environment variable names."""
|
|
100
|
+
PREFIX: str = "TIMETRACER_"
|
|
101
|
+
|
|
102
|
+
MODE: str = "TIMETRACER_MODE"
|
|
103
|
+
SERVICE: str = "TIMETRACER_SERVICE"
|
|
104
|
+
ENV: str = "TIMETRACER_ENV"
|
|
105
|
+
DIR: str = "TIMETRACER_DIR"
|
|
106
|
+
CASSETTE: str = "TIMETRACER_CASSETTE"
|
|
107
|
+
CAPTURE: str = "TIMETRACER_CAPTURE"
|
|
108
|
+
SAMPLE_RATE: str = "TIMETRACER_SAMPLE_RATE"
|
|
109
|
+
ERRORS_ONLY: str = "TIMETRACER_ERRORS_ONLY"
|
|
110
|
+
EXCLUDE_PATHS: str = "TIMETRACER_EXCLUDE_PATHS"
|
|
111
|
+
MAX_BODY_KB: str = "TIMETRACER_MAX_BODY_KB"
|
|
112
|
+
STORE_REQ_BODY: str = "TIMETRACER_STORE_REQ_BODY"
|
|
113
|
+
STORE_RES_BODY: str = "TIMETRACER_STORE_RES_BODY"
|
|
114
|
+
STRICT_REPLAY: str = "TIMETRACER_STRICT_REPLAY"
|
|
115
|
+
LOG_LEVEL: str = "TIMETRACER_LOG_LEVEL"
|
|
116
|
+
MOCK_PLUGINS: str = "TIMETRACER_MOCK_PLUGINS"
|
|
117
|
+
LIVE_PLUGINS: str = "TIMETRACER_LIVE_PLUGINS"
|
|
118
|
+
|
|
119
|
+
# =============================================================================
|
|
120
|
+
# ALLOWED HEADERS - headers we keep (allow-list approach for outbound)
|
|
121
|
+
# =============================================================================
|
|
122
|
+
ALLOWED_HEADERS: frozenset[str] = frozenset({
|
|
123
|
+
"content-type",
|
|
124
|
+
"content-length",
|
|
125
|
+
"accept",
|
|
126
|
+
"user-agent",
|
|
127
|
+
"x-request-id",
|
|
128
|
+
"x-correlation-id",
|
|
129
|
+
})
|
timetracer/context.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context management for Timetracer.
|
|
3
|
+
|
|
4
|
+
Uses contextvars to provide async-safe, per-request session isolation.
|
|
5
|
+
This ensures concurrent requests don't mix their captured events.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextvars import ContextVar, Token
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from timetracer.session import BaseSession
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# The central context variable holding the current session
|
|
18
|
+
# This is the ONLY place where session state is stored
|
|
19
|
+
_current_session: ContextVar[BaseSession | None] = ContextVar(
|
|
20
|
+
"timetracer_current_session",
|
|
21
|
+
default=None
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_current_session() -> BaseSession | None:
|
|
26
|
+
"""
|
|
27
|
+
Get the current trace session, if any.
|
|
28
|
+
|
|
29
|
+
Returns None if no session is active (timetrace disabled or outside request).
|
|
30
|
+
"""
|
|
31
|
+
return _current_session.get()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_session(session: BaseSession) -> Token[BaseSession | None]:
|
|
35
|
+
"""
|
|
36
|
+
Set the current trace session.
|
|
37
|
+
|
|
38
|
+
Returns a token that must be used to reset the session later.
|
|
39
|
+
This pattern ensures proper cleanup even with exceptions.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
token = set_session(my_session)
|
|
43
|
+
try:
|
|
44
|
+
# ... do work ...
|
|
45
|
+
finally:
|
|
46
|
+
reset_session(token)
|
|
47
|
+
"""
|
|
48
|
+
return _current_session.set(session)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reset_session(token: Token[BaseSession | None]) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Reset the session to its previous value using the token.
|
|
54
|
+
|
|
55
|
+
This should be called in a finally block to ensure cleanup.
|
|
56
|
+
"""
|
|
57
|
+
_current_session.reset(token)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clear_session() -> None:
|
|
61
|
+
"""
|
|
62
|
+
Clear the current session (set to None).
|
|
63
|
+
|
|
64
|
+
This is a convenience method; prefer reset_session(token) when possible.
|
|
65
|
+
"""
|
|
66
|
+
_current_session.set(None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def require_session() -> BaseSession:
|
|
70
|
+
"""
|
|
71
|
+
Get the current session, raising if none exists.
|
|
72
|
+
|
|
73
|
+
Use this in plugins that must have an active session.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
RuntimeError: If no session is active.
|
|
77
|
+
"""
|
|
78
|
+
session = _current_session.get()
|
|
79
|
+
if session is None:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"No active Timetracer session. "
|
|
82
|
+
"Ensure you're inside a traced request and timetrace is enabled."
|
|
83
|
+
)
|
|
84
|
+
return session
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def has_active_session() -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Check if there's an active trace session.
|
|
90
|
+
|
|
91
|
+
Useful for plugins to decide whether to capture events.
|
|
92
|
+
"""
|
|
93
|
+
return _current_session.get() is not None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dashboard module for Timetracer.
|
|
3
|
+
|
|
4
|
+
Generates an HTML dashboard to browse and filter cassettes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from timetracer.dashboard.generator import DashboardData, generate_dashboard
|
|
8
|
+
from timetracer.dashboard.template import render_dashboard_html
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DashboardData",
|
|
12
|
+
"generate_dashboard",
|
|
13
|
+
"render_dashboard_html",
|
|
14
|
+
]
|