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,229 @@
1
+ """
2
+ Dashboard data generator.
3
+
4
+ Scans cassette directories and builds data for the dashboard view.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class CassetteSummary:
17
+ """Summary of a single cassette for dashboard display."""
18
+
19
+ path: str
20
+ filename: str
21
+ method: str
22
+ endpoint: str
23
+ status: int
24
+ duration_ms: float
25
+ recorded_at: str
26
+ event_count: int
27
+ is_error: bool
28
+ service: str = ""
29
+ env: str = ""
30
+
31
+ # For expandable details
32
+ request_headers: dict[str, str] = field(default_factory=dict)
33
+ response_headers: dict[str, str] = field(default_factory=dict)
34
+ events: list[dict[str, Any]] = field(default_factory=list)
35
+ error_info: dict[str, Any] | None = None # Stack trace, error type, message
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Convert to dictionary for JSON serialization."""
39
+ return {
40
+ "path": self.path,
41
+ "filename": self.filename,
42
+ "method": self.method,
43
+ "endpoint": self.endpoint,
44
+ "status": self.status,
45
+ "duration_ms": self.duration_ms,
46
+ "recorded_at": self.recorded_at,
47
+ "event_count": self.event_count,
48
+ "is_error": self.is_error,
49
+ "service": self.service,
50
+ "env": self.env,
51
+ "request_headers": self.request_headers,
52
+ "response_headers": self.response_headers,
53
+ "events": self.events,
54
+ "error_info": self.error_info,
55
+ }
56
+
57
+
58
+ @dataclass
59
+ class DashboardData:
60
+ """Complete dashboard data for rendering."""
61
+
62
+ title: str
63
+ cassette_dir: str
64
+ generated_at: str
65
+ cassettes: list[CassetteSummary] = field(default_factory=list)
66
+
67
+ # Stats
68
+ total_count: int = 0
69
+ error_count: int = 0
70
+ success_count: int = 0
71
+
72
+ # Unique values for filters
73
+ methods: list[str] = field(default_factory=list)
74
+ endpoints: list[str] = field(default_factory=list)
75
+ statuses: list[int] = field(default_factory=list)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ """Convert to dictionary for JSON/template use."""
79
+ return {
80
+ "title": self.title,
81
+ "cassette_dir": self.cassette_dir,
82
+ "generated_at": self.generated_at,
83
+ "cassettes": [c.to_dict() for c in self.cassettes],
84
+ "stats": {
85
+ "total": self.total_count,
86
+ "errors": self.error_count,
87
+ "success": self.success_count,
88
+ },
89
+ "filters": {
90
+ "methods": self.methods,
91
+ "endpoints": self.endpoints,
92
+ "statuses": self.statuses,
93
+ },
94
+ }
95
+
96
+
97
+ def generate_dashboard(cassette_dir: str, limit: int = 500) -> DashboardData:
98
+ """
99
+ Generate dashboard data from a cassette directory.
100
+
101
+ Args:
102
+ cassette_dir: Path to the cassettes directory.
103
+ limit: Maximum number of cassettes to include.
104
+
105
+ Returns:
106
+ DashboardData ready for rendering.
107
+ """
108
+ from datetime import datetime
109
+
110
+ dir_path = Path(cassette_dir).resolve()
111
+
112
+ dashboard = DashboardData(
113
+ title="Timetracer Dashboard",
114
+ cassette_dir=str(dir_path),
115
+ generated_at=datetime.now().isoformat(),
116
+ )
117
+
118
+ if not dir_path.exists():
119
+ return dashboard
120
+
121
+ # Find all cassette files
122
+ cassette_files: list[tuple[Path, float]] = []
123
+ for json_file in dir_path.rglob("*.json"):
124
+ # Skip index files
125
+ if json_file.name == "index.json":
126
+ continue
127
+ try:
128
+ mtime = json_file.stat().st_mtime
129
+ cassette_files.append((json_file, mtime))
130
+ except OSError:
131
+ continue
132
+
133
+ # Sort by modification time (newest first)
134
+ cassette_files.sort(key=lambda x: x[1], reverse=True)
135
+
136
+ # Limit results
137
+ cassette_files = cassette_files[:limit]
138
+
139
+ # Track unique values for filters
140
+ methods_set: set[str] = set()
141
+ endpoints_set: set[str] = set()
142
+ statuses_set: set[int] = set()
143
+
144
+ # Process each cassette
145
+ for file_path, _ in cassette_files:
146
+ try:
147
+ summary = _load_cassette_summary(file_path, dir_path)
148
+ if summary:
149
+ dashboard.cassettes.append(summary)
150
+
151
+ # Track filters
152
+ methods_set.add(summary.method)
153
+ endpoints_set.add(summary.endpoint)
154
+ statuses_set.add(summary.status)
155
+
156
+ # Track stats
157
+ if summary.is_error:
158
+ dashboard.error_count += 1
159
+ else:
160
+ dashboard.success_count += 1
161
+ except Exception:
162
+ # Skip malformed cassettes
163
+ continue
164
+
165
+ dashboard.total_count = len(dashboard.cassettes)
166
+ dashboard.methods = sorted(methods_set)
167
+ dashboard.endpoints = sorted(endpoints_set)
168
+ dashboard.statuses = sorted(statuses_set)
169
+
170
+ return dashboard
171
+
172
+
173
+ def _load_cassette_summary(file_path: Path, base_dir: Path) -> CassetteSummary | None:
174
+ """Load a cassette file and extract summary data."""
175
+ try:
176
+ with open(file_path, "r", encoding="utf-8") as f:
177
+ data = json.load(f)
178
+ except (json.JSONDecodeError, OSError):
179
+ return None
180
+
181
+ # Extract request info
182
+ request = data.get("request", {})
183
+ response = data.get("response", {})
184
+ session = data.get("session", {})
185
+ events = data.get("events", [])
186
+
187
+ method = request.get("method", "UNKNOWN")
188
+ path = request.get("route_template") or request.get("path", "/unknown")
189
+ status = response.get("status", 0)
190
+ duration_ms = response.get("duration_ms", 0)
191
+ recorded_at = session.get("recorded_at", "")
192
+
193
+ # Extract headers (redacted versions are fine)
194
+ req_headers = request.get("headers", {})
195
+ res_headers = response.get("headers", {})
196
+
197
+ # Build event summaries
198
+ event_summaries = []
199
+ for event in events:
200
+ sig = event.get("signature", {})
201
+ result = event.get("result", {})
202
+ event_summaries.append({
203
+ "type": event.get("event_type", "unknown"),
204
+ "method": sig.get("method", ""),
205
+ "url": sig.get("url", ""),
206
+ "status": result.get("status"),
207
+ "duration_ms": event.get("duration_ms", 0),
208
+ })
209
+
210
+ # Extract error info if present
211
+ error_info = data.get("error_info")
212
+
213
+ return CassetteSummary(
214
+ path=str(file_path),
215
+ filename=file_path.name,
216
+ method=method,
217
+ endpoint=path,
218
+ status=status,
219
+ duration_ms=duration_ms,
220
+ recorded_at=recorded_at,
221
+ event_count=len(events),
222
+ is_error=status >= 400,
223
+ service=session.get("service", ""),
224
+ env=session.get("env", ""),
225
+ request_headers=req_headers if isinstance(req_headers, dict) else {},
226
+ response_headers=res_headers if isinstance(res_headers, dict) else {},
227
+ events=event_summaries,
228
+ error_info=error_info,
229
+ )
@@ -0,0 +1,244 @@
1
+ """
2
+ Live dashboard server with replay capability.
3
+
4
+ Provides a web interface with actual replay functionality.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from timetracer.dashboard.generator import generate_dashboard
16
+ from timetracer.dashboard.template import render_dashboard_html
17
+
18
+
19
+ class DashboardHandler(SimpleHTTPRequestHandler):
20
+ """HTTP handler for the dashboard server."""
21
+
22
+ cassette_dir: str = "./cassettes"
23
+ app_command: str = "uvicorn app:app"
24
+
25
+ def do_GET(self) -> None:
26
+ """Handle GET requests."""
27
+ parsed = urlparse(self.path)
28
+
29
+ if parsed.path == "/" or parsed.path == "/dashboard":
30
+ self._serve_dashboard()
31
+ elif parsed.path == "/api/cassettes":
32
+ self._serve_cassettes_api()
33
+ elif parsed.path == "/api/cassette":
34
+ self._serve_cassette_detail(parsed.query)
35
+ else:
36
+ self.send_error(404, "Not Found")
37
+
38
+ def do_POST(self) -> None:
39
+ """Handle POST requests."""
40
+ parsed = urlparse(self.path)
41
+
42
+ if parsed.path == "/api/replay":
43
+ self._handle_replay()
44
+ else:
45
+ self.send_error(404, "Not Found")
46
+
47
+ def _serve_dashboard(self) -> None:
48
+ """Serve the dashboard HTML with live features."""
49
+ dashboard_data = generate_dashboard(self.cassette_dir, limit=500)
50
+ html = render_live_dashboard_html(dashboard_data)
51
+
52
+ self.send_response(200)
53
+ self.send_header("Content-type", "text/html; charset=utf-8")
54
+ self.end_headers()
55
+ self.wfile.write(html.encode("utf-8"))
56
+
57
+ def _serve_cassettes_api(self) -> None:
58
+ """Serve cassettes as JSON API."""
59
+ dashboard_data = generate_dashboard(self.cassette_dir, limit=500)
60
+
61
+ self.send_response(200)
62
+ self.send_header("Content-type", "application/json")
63
+ self.end_headers()
64
+ self.wfile.write(json.dumps(dashboard_data.to_dict()).encode("utf-8"))
65
+
66
+ def _serve_cassette_detail(self, query: str) -> None:
67
+ """Serve full cassette JSON."""
68
+ params = parse_qs(query)
69
+ path = params.get("path", [None])[0]
70
+
71
+ if not path or not Path(path).exists():
72
+ self.send_error(404, "Cassette not found")
73
+ return
74
+
75
+ try:
76
+ with open(path, "r", encoding="utf-8") as f:
77
+ cassette = json.load(f)
78
+
79
+ self.send_response(200)
80
+ self.send_header("Content-type", "application/json")
81
+ self.end_headers()
82
+ self.wfile.write(json.dumps(cassette, indent=2).encode("utf-8"))
83
+ except Exception as e:
84
+ self.send_error(500, str(e))
85
+
86
+ def _handle_replay(self) -> None:
87
+ """Handle replay request."""
88
+ content_length = int(self.headers.get("Content-Length", 0))
89
+ body = self.rfile.read(content_length)
90
+
91
+ try:
92
+ data = json.loads(body)
93
+ cassette_path = data.get("cassette_path")
94
+
95
+ if not cassette_path or not Path(cassette_path).exists():
96
+ self._send_json({"error": "Cassette not found"}, 404)
97
+ return
98
+
99
+ # Load the cassette
100
+ with open(cassette_path, "r", encoding="utf-8") as f:
101
+ cassette = json.load(f)
102
+
103
+ request = cassette.get("request", {})
104
+ response = cassette.get("response", {})
105
+ events = cassette.get("events", [])
106
+
107
+ # Return the replay data (simulated replay without starting server)
108
+ replay_result = {
109
+ "success": True,
110
+ "cassette_path": cassette_path,
111
+ "request": {
112
+ "method": request.get("method"),
113
+ "path": request.get("path"),
114
+ "headers": request.get("headers", {}),
115
+ },
116
+ "response": {
117
+ "status": response.get("status"),
118
+ "duration_ms": response.get("duration_ms"),
119
+ "body": response.get("body"),
120
+ },
121
+ "events": [
122
+ {
123
+ "type": e.get("event_type"),
124
+ "url": e.get("signature", {}).get("url"),
125
+ "status": e.get("result", {}).get("status"),
126
+ "duration_ms": e.get("duration_ms"),
127
+ }
128
+ for e in events
129
+ ],
130
+ "message": "Replay data loaded. This shows what would happen if you replayed this cassette.",
131
+ }
132
+
133
+ self._send_json(replay_result, 200)
134
+
135
+ except Exception as e:
136
+ self._send_json({"error": str(e)}, 500)
137
+
138
+ def _send_json(self, data: dict[str, Any], status: int = 200) -> None:
139
+ """Send JSON response."""
140
+ self.send_response(status)
141
+ self.send_header("Content-type", "application/json")
142
+ self.end_headers()
143
+ self.wfile.write(json.dumps(data).encode("utf-8"))
144
+
145
+ def log_message(self, format: str, *args: Any) -> None:
146
+ """Log requests to console."""
147
+ print(f"[{self.command}] {self.path} - {args[1] if len(args) > 1 else ''}")
148
+
149
+
150
+ def render_live_dashboard_html(data: Any) -> str:
151
+ """Render dashboard with live replay capability."""
152
+ # Get the base dashboard HTML
153
+ base_html = render_dashboard_html(data)
154
+
155
+ # Inject live replay script
156
+ live_script = """
157
+ <script>
158
+ // Override replay button to use live API
159
+ function liveReplay(cassettePath) {
160
+ const modal = document.getElementById('detail-modal');
161
+ const body = document.getElementById('modal-body');
162
+
163
+ body.innerHTML = '<div style="text-align:center;padding:40px;"><h3>Loading replay...</h3></div>';
164
+ modal.classList.add('show');
165
+
166
+ fetch('/api/replay', {
167
+ method: 'POST',
168
+ headers: {'Content-Type': 'application/json'},
169
+ body: JSON.stringify({cassette_path: cassettePath})
170
+ })
171
+ .then(r => r.json())
172
+ .then(result => {
173
+ if (result.error) {
174
+ body.innerHTML = '<div style="color:#ff6b6b;padding:20px;">Error: ' + result.error + '</div>';
175
+ return;
176
+ }
177
+
178
+ body.innerHTML = `
179
+ <div class="detail-section">
180
+ <h3 style="color:#00ff88;">Replay Result</h3>
181
+ <div class="detail-grid">
182
+ <span class="detail-label">Request</span>
183
+ <span class="detail-value">${result.request.method} ${result.request.path}</span>
184
+ <span class="detail-label">Status</span>
185
+ <span class="detail-value"><span class="status-badge ${result.response.status >= 400 ? 'status-error' : 'status-success'}">${result.response.status}</span></span>
186
+ <span class="detail-label">Duration</span>
187
+ <span class="detail-value">${result.response.duration_ms.toFixed(2)}ms</span>
188
+ </div>
189
+ </div>
190
+
191
+ ${result.events.length > 0 ? `
192
+ <div class="detail-section">
193
+ <h3>Mocked Dependencies (${result.events.length})</h3>
194
+ <div class="events-list">
195
+ ${result.events.map(e => `
196
+ <div class="event-item">
197
+ <span>${e.type}</span>
198
+ <span class="event-url">${e.url || '-'}</span>
199
+ <span class="status-badge ${(e.status || 200) >= 400 ? 'status-error' : 'status-success'}">${e.status || '-'}</span>
200
+ <span>${e.duration_ms.toFixed(0)}ms</span>
201
+ </div>
202
+ `).join('')}
203
+ </div>
204
+ </div>
205
+ ` : ''}
206
+
207
+ ${result.response.body ? `
208
+ <div class="detail-section">
209
+ <h3>Response Body</h3>
210
+ <pre style="background:rgba(0,0,0,0.4);padding:16px;border-radius:8px;max-height:300px;overflow:auto;font-size:0.8rem;color:#98c379;">${typeof result.response.body === 'string' ? result.response.body : JSON.stringify(result.response.body, null, 2)}</pre>
211
+ </div>
212
+ ` : ''}
213
+
214
+ <div class="detail-section">
215
+ <p style="color:#888;font-size:0.85rem;">${result.message}</p>
216
+ </div>
217
+ `;
218
+ })
219
+ .catch(err => {
220
+ body.innerHTML = '<div style="color:#ff6b6b;padding:20px;">Network error: ' + err.message + '</div>';
221
+ });
222
+ }
223
+ </script>
224
+ """
225
+
226
+ # Insert before </body>
227
+ return base_html.replace("</body>", live_script + "</body>")
228
+
229
+
230
+ def start_server(cassette_dir: str, port: int = 8765) -> None:
231
+ """Start the dashboard server."""
232
+ DashboardHandler.cassette_dir = cassette_dir
233
+
234
+ server = HTTPServer(("", port), DashboardHandler)
235
+
236
+ print(f"Dashboard server running at http://localhost:{port}")
237
+ print(f"Serving cassettes from: {cassette_dir}")
238
+ print("Press Ctrl+C to stop")
239
+
240
+ try:
241
+ server.serve_forever()
242
+ except KeyboardInterrupt:
243
+ print("\nShutting down...")
244
+ server.shutdown()