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.
Files changed (51) hide show
  1. timetracer/__init__.py +29 -0
  2. timetracer/cassette/__init__.py +6 -0
  3. timetracer/cassette/io.py +421 -0
  4. timetracer/cassette/naming.py +69 -0
  5. timetracer/catalog/__init__.py +288 -0
  6. timetracer/cli/__init__.py +5 -0
  7. timetracer/cli/commands/__init__.py +1 -0
  8. timetracer/cli/main.py +692 -0
  9. timetracer/config.py +297 -0
  10. timetracer/constants.py +129 -0
  11. timetracer/context.py +93 -0
  12. timetracer/dashboard/__init__.py +14 -0
  13. timetracer/dashboard/generator.py +229 -0
  14. timetracer/dashboard/server.py +244 -0
  15. timetracer/dashboard/template.py +874 -0
  16. timetracer/diff/__init__.py +6 -0
  17. timetracer/diff/engine.py +311 -0
  18. timetracer/diff/report.py +113 -0
  19. timetracer/exceptions.py +113 -0
  20. timetracer/integrations/__init__.py +27 -0
  21. timetracer/integrations/fastapi.py +537 -0
  22. timetracer/integrations/flask.py +507 -0
  23. timetracer/plugins/__init__.py +42 -0
  24. timetracer/plugins/base.py +73 -0
  25. timetracer/plugins/httpx_plugin.py +413 -0
  26. timetracer/plugins/redis_plugin.py +297 -0
  27. timetracer/plugins/requests_plugin.py +333 -0
  28. timetracer/plugins/sqlalchemy_plugin.py +280 -0
  29. timetracer/policies/__init__.py +16 -0
  30. timetracer/policies/capture.py +64 -0
  31. timetracer/policies/redaction.py +165 -0
  32. timetracer/replay/__init__.py +6 -0
  33. timetracer/replay/engine.py +75 -0
  34. timetracer/replay/errors.py +9 -0
  35. timetracer/replay/matching.py +83 -0
  36. timetracer/session.py +390 -0
  37. timetracer/storage/__init__.py +18 -0
  38. timetracer/storage/s3.py +364 -0
  39. timetracer/timeline/__init__.py +6 -0
  40. timetracer/timeline/generator.py +150 -0
  41. timetracer/timeline/template.py +370 -0
  42. timetracer/types.py +197 -0
  43. timetracer/utils/__init__.py +6 -0
  44. timetracer/utils/hashing.py +68 -0
  45. timetracer/utils/time.py +106 -0
  46. timetracer-1.1.0.dist-info/METADATA +286 -0
  47. timetracer-1.1.0.dist-info/RECORD +51 -0
  48. timetracer-1.1.0.dist-info/WHEEL +5 -0
  49. timetracer-1.1.0.dist-info/entry_points.txt +2 -0
  50. timetracer-1.1.0.dist-info/licenses/LICENSE +21 -0
  51. 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
@@ -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
+ ]