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
@@ -0,0 +1,507 @@
1
+ """
2
+ Flask integration for Timetracer.
3
+
4
+ This is the main integration point for Flask applications.
5
+ It handles request/response capture and session lifecycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import time
13
+ from typing import TYPE_CHECKING, Any, Callable
14
+
15
+ from timetracer.cassette import read_cassette, write_cassette
16
+ from timetracer.config import TraceConfig
17
+ from timetracer.context import reset_session, set_session
18
+ from timetracer.policies import redact_body, redact_headers
19
+ from timetracer.session import ReplaySession, TraceSession
20
+ from timetracer.types import BodySnapshot, RequestSnapshot, ResponseSnapshot
21
+ from timetracer.utils.hashing import hash_body
22
+
23
+ if TYPE_CHECKING:
24
+ from flask import Flask
25
+
26
+
27
+ class TimeTraceMiddleware:
28
+ """
29
+ WSGI middleware for Timetracer integration with Flask.
30
+
31
+ Handles:
32
+ - Session lifecycle (create, attach, finalize)
33
+ - Request/response capture
34
+ - Cassette writing (record mode)
35
+ - Cassette loading (replay mode)
36
+ - Terminal summary output
37
+
38
+ Usage:
39
+ from flask import Flask
40
+ from timetracer.integrations.flask import timetracerMiddleware
41
+ from timetracer.config import TraceConfig
42
+
43
+ app = Flask(__name__)
44
+ config = TraceConfig(mode="record", cassette_dir="./cassettes")
45
+ app.wsgi_app = TimeTraceMiddleware(app.wsgi_app, config=config)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ app: Any, # WSGI app
51
+ config: TraceConfig | None = None,
52
+ ) -> None:
53
+ """
54
+ Initialize middleware.
55
+
56
+ Args:
57
+ app: The WSGI application.
58
+ config: Timetracer configuration. If None, loads from environment.
59
+ """
60
+ self.app = app
61
+ self.config = config or TraceConfig.from_env()
62
+
63
+ def __call__(
64
+ self,
65
+ environ: dict[str, Any],
66
+ start_response: Callable,
67
+ ) -> Any:
68
+ """Handle WSGI request."""
69
+ # Check if timetrace is enabled
70
+ if not self.config.is_enabled:
71
+ return self.app(environ, start_response)
72
+
73
+ # Get request path
74
+ path = environ.get("PATH_INFO", "/")
75
+
76
+ # Check if path should be traced
77
+ if not self.config.should_trace(path):
78
+ return self.app(environ, start_response)
79
+
80
+ # Check sampling (only for record mode)
81
+ if self.config.is_record_mode and not self.config.should_sample():
82
+ return self.app(environ, start_response)
83
+
84
+ # Route to appropriate handler
85
+ if self.config.is_record_mode:
86
+ return self._handle_record(environ, start_response)
87
+ elif self.config.is_replay_mode:
88
+ return self._handle_replay(environ, start_response)
89
+ else:
90
+ return self.app(environ, start_response)
91
+
92
+ def _handle_record(
93
+ self,
94
+ environ: dict[str, Any],
95
+ start_response: Callable,
96
+ ) -> Any:
97
+ """Handle request in record mode."""
98
+ # Create session
99
+ session = TraceSession(config=self.config)
100
+ token = set_session(session)
101
+
102
+ start_time = time.perf_counter()
103
+
104
+ try:
105
+ # Capture request
106
+ request_snapshot = self._capture_request(environ)
107
+ session.set_request(request_snapshot)
108
+
109
+ # Track response
110
+ response_started = False
111
+ response_status = 0
112
+ response_headers: dict[str, str] = {}
113
+ response_body_parts: list[bytes] = []
114
+
115
+ def capturing_start_response(status: str, headers: list, exc_info=None):
116
+ nonlocal response_started, response_status, response_headers
117
+ response_started = True
118
+ # Parse status code from "200 OK"
119
+ response_status = int(status.split()[0])
120
+ response_headers = {k: v for k, v in headers}
121
+ return start_response(status, headers, exc_info)
122
+
123
+ # Call the app
124
+ is_error = False
125
+ try:
126
+ response = self.app(environ, capturing_start_response)
127
+ # Collect response body
128
+ for chunk in response:
129
+ response_body_parts.append(chunk)
130
+ yield chunk
131
+ if hasattr(response, 'close'):
132
+ response.close()
133
+ except Exception as e:
134
+ is_error = True
135
+ session.mark_error(
136
+ error_type=type(e).__name__,
137
+ error_message=str(e),
138
+ )
139
+ raise
140
+ finally:
141
+ # Calculate duration
142
+ duration_ms = (time.perf_counter() - start_time) * 1000
143
+
144
+ # Capture response
145
+ is_error = is_error or response_status >= 400
146
+ response_body = b"".join(response_body_parts)
147
+
148
+ response_snapshot = self._build_response_snapshot(
149
+ status=response_status,
150
+ headers=response_headers,
151
+ body=response_body,
152
+ duration_ms=duration_ms,
153
+ is_error=is_error,
154
+ )
155
+ session.set_response(response_snapshot)
156
+
157
+ # Finalize and write cassette
158
+ session.finalize()
159
+
160
+ # Only write if errors_only is False, or if there was an error
161
+ if not self.config.errors_only or is_error:
162
+ cassette_path = write_cassette(session, self.config)
163
+ self._print_record_summary(session, cassette_path)
164
+
165
+ finally:
166
+ reset_session(token)
167
+
168
+ def _handle_replay(
169
+ self,
170
+ environ: dict[str, Any],
171
+ start_response: Callable,
172
+ ) -> Any:
173
+ """Handle request in replay mode."""
174
+ # Load cassette
175
+ cassette_path = self.config.cassette_path
176
+ if not cassette_path:
177
+ print("timetracer [WARN] replay mode requires TIMETRACER_CASSETTE", file=sys.stderr)
178
+ return self.app(environ, start_response)
179
+
180
+ cassette = read_cassette(cassette_path)
181
+
182
+ # Create replay session
183
+ session = ReplaySession(
184
+ cassette=cassette,
185
+ cassette_path=cassette_path,
186
+ strict=self.config.strict_replay,
187
+ config=self.config,
188
+ )
189
+ token = set_session(session)
190
+
191
+ start_time = time.perf_counter()
192
+
193
+ try:
194
+ # Run the app (plugins will intercept dependency calls)
195
+ response = self.app(environ, start_response)
196
+ response_body = b"".join(response)
197
+ if hasattr(response, 'close'):
198
+ response.close()
199
+
200
+ duration_ms = (time.perf_counter() - start_time) * 1000
201
+ self._print_replay_summary(session, duration_ms)
202
+
203
+ yield response_body
204
+
205
+ finally:
206
+ reset_session(token)
207
+
208
+ def _capture_request(self, environ: dict[str, Any]) -> RequestSnapshot:
209
+ """Capture incoming request data."""
210
+ method = environ.get("REQUEST_METHOD", "GET")
211
+ path = environ.get("PATH_INFO", "/")
212
+
213
+ # Headers (from environ)
214
+ headers = {}
215
+ for key, value in environ.items():
216
+ if key.startswith("HTTP_"):
217
+ header_name = key[5:].replace("_", "-").lower()
218
+ headers[header_name] = value
219
+ elif key in ("CONTENT_TYPE", "CONTENT_LENGTH"):
220
+ header_name = key.replace("_", "-").lower()
221
+ headers[header_name] = value
222
+
223
+ # Redact sensitive headers
224
+ headers = redact_headers(headers)
225
+
226
+ # Query params
227
+ query_string = environ.get("QUERY_STRING", "")
228
+ query = self._parse_query_string(query_string)
229
+
230
+ # Client info
231
+ client_ip = environ.get("REMOTE_ADDR")
232
+ user_agent = headers.get("user-agent")
233
+
234
+ # Body
235
+ body_snapshot = self._capture_request_body(environ)
236
+
237
+ return RequestSnapshot(
238
+ method=method,
239
+ path=path,
240
+ route_template=None, # Flask doesn't expose this easily
241
+ headers=headers,
242
+ query=query,
243
+ body=body_snapshot,
244
+ client_ip=client_ip,
245
+ user_agent=user_agent,
246
+ )
247
+
248
+ def _capture_request_body(self, environ: dict[str, Any]) -> BodySnapshot | None:
249
+ """Capture request body data."""
250
+ try:
251
+ content_length = int(environ.get("CONTENT_LENGTH", 0))
252
+ except (ValueError, TypeError):
253
+ content_length = 0
254
+
255
+ if content_length == 0:
256
+ return None
257
+
258
+ # Read body
259
+ wsgi_input = environ.get("wsgi.input")
260
+ if not wsgi_input:
261
+ return None
262
+
263
+ body = wsgi_input.read(content_length)
264
+
265
+ # Put it back for the app to read
266
+ from io import BytesIO
267
+ environ["wsgi.input"] = BytesIO(body)
268
+
269
+ if not body:
270
+ return None
271
+
272
+ # Check size
273
+ size_bytes = len(body)
274
+ max_bytes = self.config.max_body_kb * 1024
275
+ truncated = size_bytes > max_bytes
276
+
277
+ if truncated:
278
+ body = body[:max_bytes]
279
+
280
+ # Try to parse as JSON
281
+ encoding = "bytes"
282
+ data: Any = None
283
+
284
+ try:
285
+ data = json.loads(body.decode("utf-8"))
286
+ encoding = "json"
287
+ data = redact_body(data)
288
+ except (json.JSONDecodeError, UnicodeDecodeError):
289
+ encoding = "bytes"
290
+ data = None
291
+
292
+ return BodySnapshot(
293
+ captured=True,
294
+ encoding=encoding,
295
+ data=data,
296
+ truncated=truncated,
297
+ size_bytes=size_bytes,
298
+ hash=hash_body(body),
299
+ )
300
+
301
+ def _build_response_snapshot(
302
+ self,
303
+ status: int,
304
+ headers: dict[str, str],
305
+ body: bytes,
306
+ duration_ms: float,
307
+ is_error: bool,
308
+ ) -> ResponseSnapshot:
309
+ """Build response snapshot with policy-based capture."""
310
+ from timetracer.policies import should_store_body
311
+
312
+ # Redact headers
313
+ headers = redact_headers(headers)
314
+
315
+ # Check if we should store body
316
+ should_store = should_store_body(
317
+ self.config.store_response_body,
318
+ is_error=is_error,
319
+ )
320
+
321
+ body_snapshot = None
322
+ if should_store and body:
323
+ size_bytes = len(body)
324
+ max_bytes = self.config.max_body_kb * 1024
325
+ truncated = size_bytes > max_bytes
326
+
327
+ if truncated:
328
+ body = body[:max_bytes]
329
+
330
+ # Try to parse as JSON
331
+ encoding = "bytes"
332
+ data: Any = None
333
+
334
+ try:
335
+ data = json.loads(body.decode("utf-8"))
336
+ encoding = "json"
337
+ data = redact_body(data)
338
+ except (json.JSONDecodeError, UnicodeDecodeError):
339
+ encoding = "bytes"
340
+ data = None
341
+
342
+ body_snapshot = BodySnapshot(
343
+ captured=True,
344
+ encoding=encoding,
345
+ data=data,
346
+ truncated=truncated,
347
+ size_bytes=size_bytes,
348
+ hash=hash_body(body),
349
+ )
350
+ elif body:
351
+ # Just store hash
352
+ body_snapshot = BodySnapshot(
353
+ captured=False,
354
+ hash=hash_body(body),
355
+ size_bytes=len(body),
356
+ )
357
+
358
+ return ResponseSnapshot(
359
+ status=status,
360
+ headers=headers,
361
+ body=body_snapshot,
362
+ duration_ms=duration_ms,
363
+ )
364
+
365
+ def _parse_query_string(self, query_string: str) -> dict[str, str]:
366
+ """Parse query string into dict."""
367
+ if not query_string:
368
+ return {}
369
+
370
+ try:
371
+ from urllib.parse import parse_qs
372
+ parsed = parse_qs(query_string)
373
+ # Flatten to single values (take first)
374
+ return {k: v[0] if v else "" for k, v in parsed.items()}
375
+ except Exception:
376
+ return {}
377
+
378
+ def _print_record_summary(self, session: TraceSession, cassette_path: str) -> None:
379
+ """Print terminal summary for record mode."""
380
+ req = session.request
381
+ res = session.response
382
+
383
+ method = req.method if req else "???"
384
+ path = req.path if req else "???"
385
+ status = res.status if res else 0
386
+ duration_ms = res.duration_ms if res else 0
387
+
388
+ # Event counts
389
+ event_counts = {}
390
+ for event in session.events:
391
+ et = event.event_type.value
392
+ event_counts[et] = event_counts.get(et, 0) + 1
393
+
394
+ deps_str = ", ".join(f"{k}:{v}" for k, v in event_counts.items()) or "none"
395
+
396
+ # Status icon
397
+ icon = "[OK]" if status < 400 else "[WARN]"
398
+
399
+ print(
400
+ f"timetracer {icon} recorded {method} {path} "
401
+ f"id={session.short_id} status={status} "
402
+ f"total={duration_ms:.0f}ms deps={deps_str}",
403
+ file=sys.stderr,
404
+ )
405
+ print(f" cassette: {cassette_path}", file=sys.stderr)
406
+
407
+ def _print_replay_summary(self, session: ReplaySession, duration_ms: float) -> None:
408
+ """Print terminal summary for replay mode."""
409
+ req = session.request
410
+ method = req.method
411
+ path = req.path
412
+
413
+ recorded_duration = session.cassette.response.duration_ms
414
+
415
+ # Check for unconsumed events
416
+ unconsumed = len(session.get_unconsumed_events())
417
+ match_status = "OK" if unconsumed == 0 else f"WARN ({unconsumed} unconsumed)"
418
+
419
+ # Mocked counts
420
+ mocked_count = session.current_cursor
421
+
422
+ print(
423
+ f"timetracer replay {method} {path} "
424
+ f"mocked={mocked_count} matched={match_status} "
425
+ f"runtime={duration_ms:.0f}ms recorded={recorded_duration:.0f}ms",
426
+ file=sys.stderr,
427
+ )
428
+
429
+
430
+ def init_app(app: "Flask", config: TraceConfig | None = None) -> None:
431
+ """
432
+ Initialize Timetracer for a Flask app.
433
+
434
+ Alternative to wrapping wsgi_app directly.
435
+
436
+ Args:
437
+ app: Flask application instance.
438
+ config: Timetracer configuration.
439
+
440
+ Usage:
441
+ from flask import Flask
442
+ from timetracer.integrations.flask import init_app
443
+ from timetracer.config import TraceConfig
444
+
445
+ app = Flask(__name__)
446
+ init_app(app, TraceConfig(mode="record"))
447
+ """
448
+ cfg = config or TraceConfig.from_env()
449
+ app.wsgi_app = TimeTraceMiddleware(app.wsgi_app, config=cfg)
450
+
451
+
452
+ def auto_setup(
453
+ app: "Flask",
454
+ config: TraceConfig | None = None,
455
+ plugins: list[str] | None = None,
456
+ ) -> "Flask":
457
+ """
458
+ One-line Timetracer setup for Flask.
459
+
460
+ Adds middleware and enables plugins automatically.
461
+
462
+ Args:
463
+ app: Flask application instance.
464
+ config: Optional TraceConfig. If None, loads from environment.
465
+ plugins: List of plugins to enable. Default: ["requests"].
466
+ Options: "httpx", "requests", "sqlalchemy", "redis"
467
+
468
+ Returns:
469
+ The app instance (for chaining).
470
+
471
+ Usage:
472
+ from flask import Flask
473
+ from timetracer.integrations.flask import auto_setup
474
+
475
+ app = auto_setup(Flask(__name__))
476
+
477
+ # Or with options:
478
+ app = Flask(__name__)
479
+ auto_setup(app, plugins=["requests", "redis"])
480
+ """
481
+ cfg = config or TraceConfig.from_env()
482
+
483
+ # Add middleware
484
+ app.wsgi_app = TimeTraceMiddleware(app.wsgi_app, config=cfg)
485
+
486
+ # Enable plugins
487
+ enabled_plugins = plugins or ["requests"]
488
+
489
+ for plugin in enabled_plugins:
490
+ if plugin == "httpx":
491
+ from timetracer.plugins import enable_httpx
492
+ enable_httpx()
493
+ elif plugin == "requests":
494
+ from timetracer.plugins import enable_requests
495
+ enable_requests()
496
+ elif plugin == "sqlalchemy":
497
+ from timetracer.plugins import enable_sqlalchemy
498
+ enable_sqlalchemy()
499
+ elif plugin == "redis":
500
+ from timetracer.plugins import enable_redis
501
+ enable_redis()
502
+
503
+ return app
504
+
505
+
506
+ # Backwards compatibility alias
507
+ timetracerMiddleware = TimeTraceMiddleware
@@ -0,0 +1,42 @@
1
+ """
2
+ Plugins module for Timetracer.
3
+
4
+ Plugins capture and replay dependency calls (HTTP, DB, Redis, etc.).
5
+ """
6
+
7
+ from timetracer.plugins.httpx_plugin import disable_httpx, enable_httpx
8
+ from timetracer.plugins.requests_plugin import disable_requests, enable_requests
9
+
10
+ # SQLAlchemy is optional - only import if available
11
+ try:
12
+ from timetracer.plugins.sqlalchemy_plugin import disable_sqlalchemy, enable_sqlalchemy
13
+ _HAS_SQLALCHEMY = True
14
+ except ImportError:
15
+ _HAS_SQLALCHEMY = False
16
+
17
+ def enable_sqlalchemy(*args, **kwargs):
18
+ raise ImportError("sqlalchemy is required. Install with: pip install timetracer[sqlalchemy]")
19
+
20
+ def disable_sqlalchemy(*args, **kwargs):
21
+ pass
22
+
23
+ # Redis is optional - only import if available
24
+ try:
25
+ from timetracer.plugins.redis_plugin import disable_redis, enable_redis
26
+ _HAS_REDIS = True
27
+ except ImportError:
28
+ _HAS_REDIS = False
29
+
30
+ def enable_redis(*args, **kwargs):
31
+ raise ImportError("redis is required. Install with: pip install timetracer[redis]")
32
+
33
+ def disable_redis(*args, **kwargs):
34
+ pass
35
+
36
+ __all__ = [
37
+ "enable_httpx", "disable_httpx",
38
+ "enable_requests", "disable_requests",
39
+ "enable_sqlalchemy", "disable_sqlalchemy",
40
+ "enable_redis", "disable_redis",
41
+ ]
42
+
@@ -0,0 +1,73 @@
1
+ """
2
+ Base plugin infrastructure.
3
+
4
+ Defines the plugin protocol and registry.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
10
+
11
+ from timetracer.constants import EventType
12
+
13
+ if TYPE_CHECKING:
14
+ from timetracer.config import TraceConfig
15
+
16
+
17
+ @runtime_checkable
18
+ class TracePlugin(Protocol):
19
+ """
20
+ Protocol for Timetracer plugins.
21
+
22
+ Plugins must implement this interface to integrate with the system.
23
+ """
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ """Unique plugin identifier."""
28
+ ...
29
+
30
+ @property
31
+ def event_type(self) -> EventType:
32
+ """The type of events this plugin captures."""
33
+ ...
34
+
35
+ def setup(self, config: TraceConfig) -> None:
36
+ """Initialize the plugin with configuration."""
37
+ ...
38
+
39
+ def enable_recording(self) -> None:
40
+ """Start capturing events."""
41
+ ...
42
+
43
+ def enable_replay(self) -> None:
44
+ """Start mocking calls with recorded data."""
45
+ ...
46
+
47
+ def disable(self) -> None:
48
+ """Stop capturing/mocking and restore original behavior."""
49
+ ...
50
+
51
+
52
+ # Plugin registry
53
+ _registered_plugins: dict[str, TracePlugin] = {}
54
+
55
+
56
+ def register_plugin(plugin: TracePlugin) -> None:
57
+ """Register a plugin globally."""
58
+ _registered_plugins[plugin.name] = plugin
59
+
60
+
61
+ def get_plugin(name: str) -> TracePlugin | None:
62
+ """Get a registered plugin by name."""
63
+ return _registered_plugins.get(name)
64
+
65
+
66
+ def get_all_plugins() -> dict[str, TracePlugin]:
67
+ """Get all registered plugins."""
68
+ return _registered_plugins.copy()
69
+
70
+
71
+ def clear_plugins() -> None:
72
+ """Clear all registered plugins (for testing)."""
73
+ _registered_plugins.clear()