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,370 @@
1
+ """
2
+ HTML template for timeline visualization.
3
+
4
+ Generates a self-contained HTML file with embedded CSS and JavaScript.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from timetracer.timeline.generator import TimelineData
10
+
11
+
12
+ def render_timeline_html(data: TimelineData) -> str:
13
+ """
14
+ Render timeline data as a standalone HTML file.
15
+
16
+ Args:
17
+ data: Timeline data to visualize.
18
+
19
+ Returns:
20
+ Complete HTML string.
21
+ """
22
+ _events_to_json(data)
23
+
24
+ return f'''<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="UTF-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
29
+ <title>Timetracer Timeline - {data.title}</title>
30
+ <style>
31
+ {_get_css()}
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="container">
36
+ <header>
37
+ <h1>Timetracer Timeline</h1>
38
+ <div class="request-info">
39
+ <span class="method {data.method.lower()}">{data.method}</span>
40
+ <span class="path">{data.path}</span>
41
+ <span class="status status-{_status_class(data.response_status)}">{data.response_status}</span>
42
+ </div>
43
+ <div class="meta">
44
+ <span>Duration: <strong>{data.total_duration_ms:.1f}ms</strong></span>
45
+ <span>Events: <strong>{data.event_count}</strong></span>
46
+ <span>Recorded: {data.recorded_at}</span>
47
+ </div>
48
+ </header>
49
+
50
+ <div class="timeline-container">
51
+ <div class="timeline-header">
52
+ <span>0ms</span>
53
+ <span>{data.total_duration_ms / 2:.0f}ms</span>
54
+ <span>{data.total_duration_ms:.0f}ms</span>
55
+ </div>
56
+ <div class="timeline" id="timeline">
57
+ <!-- Main request bar -->
58
+ <div class="timeline-row main-request">
59
+ <div class="label">Request</div>
60
+ <div class="bar-container">
61
+ <div class="bar status-{_status_class(data.response_status)}"
62
+ style="left: 0%; width: 100%;"
63
+ title="Total: {data.total_duration_ms:.1f}ms">
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Dependency events -->
69
+ {_render_events(data)}
70
+ </div>
71
+ </div>
72
+
73
+ <div class="events-list">
74
+ <h2>Events Detail</h2>
75
+ <table>
76
+ <thead>
77
+ <tr>
78
+ <th>#</th>
79
+ <th>Type</th>
80
+ <th>URL/Label</th>
81
+ <th>Start</th>
82
+ <th>Duration</th>
83
+ <th>Status</th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ {_render_events_table(data)}
88
+ </tbody>
89
+ </table>
90
+ </div>
91
+
92
+ <footer>
93
+ <p>Generated by <strong>Timetracer</strong> v0.1.0</p>
94
+ </footer>
95
+ </div>
96
+
97
+ <script>
98
+ {_get_js()}
99
+ </script>
100
+ </body>
101
+ </html>'''
102
+
103
+
104
+ def _status_class(status: int) -> str:
105
+ """Get CSS class for status code."""
106
+ if status < 300:
107
+ return "success"
108
+ elif status < 400:
109
+ return "redirect"
110
+ elif status < 500:
111
+ return "client-error"
112
+ else:
113
+ return "server-error"
114
+
115
+
116
+ def _events_to_json(data: TimelineData) -> str:
117
+ """Convert events to JSON for JavaScript."""
118
+ import json
119
+ return json.dumps(data.to_dict()["events"])
120
+
121
+
122
+ def _render_events(data: TimelineData) -> str:
123
+ """Render timeline event bars."""
124
+ if not data.events or data.total_duration_ms == 0:
125
+ return ""
126
+
127
+ lines = []
128
+ for event in data.events:
129
+ left_pct = (event.start_ms / data.total_duration_ms) * 100
130
+ width_pct = (event.duration_ms / data.total_duration_ms) * 100
131
+ width_pct = max(width_pct, 0.5) # Minimum visible width
132
+
133
+ status_class = _status_class(event.status) if event.status else "unknown"
134
+ error_class = "error" if event.is_error else ""
135
+
136
+ # Shorten label
137
+ label = event.label
138
+ if len(label) > 40:
139
+ label = label[:37] + "..."
140
+
141
+ lines.append(f'''
142
+ <div class="timeline-row">
143
+ <div class="label" title="{event.label}">#{event.id} {event.event_type}</div>
144
+ <div class="bar-container">
145
+ <div class="bar {status_class} {error_class}"
146
+ style="left: {left_pct:.1f}%; width: {width_pct:.1f}%;"
147
+ title="{event.label}&#10;Start: {event.start_ms:.1f}ms&#10;Duration: {event.duration_ms:.1f}ms&#10;Status: {event.status}">
148
+ </div>
149
+ </div>
150
+ </div>''')
151
+
152
+ return "\n".join(lines)
153
+
154
+
155
+ def _render_events_table(data: TimelineData) -> str:
156
+ """Render events table rows."""
157
+ if not data.events:
158
+ return '<tr><td colspan="6">No events</td></tr>'
159
+
160
+ lines = []
161
+ for event in data.events:
162
+ status_class = _status_class(event.status) if event.status else "unknown"
163
+ error_indicator = "[ERR]" if event.is_error else ""
164
+
165
+ url_display = event.url or event.label
166
+ if len(url_display) > 60:
167
+ url_display = url_display[:57] + "..."
168
+
169
+ lines.append(f'''
170
+ <tr class="{status_class}">
171
+ <td>{event.id}</td>
172
+ <td>{event.event_type}</td>
173
+ <td title="{event.url}">{url_display}</td>
174
+ <td>{event.start_ms:.1f}ms</td>
175
+ <td>{event.duration_ms:.1f}ms</td>
176
+ <td><span class="status status-{status_class}">{event.status}</span> {error_indicator}</td>
177
+ </tr>''')
178
+
179
+ return "\n".join(lines)
180
+
181
+
182
+ def _get_css() -> str:
183
+ """Get embedded CSS styles."""
184
+ return '''
185
+ * {
186
+ box-sizing: border-box;
187
+ margin: 0;
188
+ padding: 0;
189
+ }
190
+
191
+ body {
192
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
193
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
194
+ color: #eee;
195
+ min-height: 100vh;
196
+ padding: 2rem;
197
+ }
198
+
199
+ .container {
200
+ max-width: 1200px;
201
+ margin: 0 auto;
202
+ }
203
+
204
+ header {
205
+ margin-bottom: 2rem;
206
+ }
207
+
208
+ h1 {
209
+ font-size: 1.8rem;
210
+ margin-bottom: 1rem;
211
+ color: #fff;
212
+ }
213
+
214
+ h2 {
215
+ font-size: 1.3rem;
216
+ margin: 2rem 0 1rem;
217
+ color: #fff;
218
+ }
219
+
220
+ .request-info {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 1rem;
224
+ margin-bottom: 0.5rem;
225
+ }
226
+
227
+ .method {
228
+ padding: 0.3rem 0.8rem;
229
+ border-radius: 4px;
230
+ font-weight: bold;
231
+ font-size: 0.9rem;
232
+ }
233
+
234
+ .method.get { background: #22c55e; color: #000; }
235
+ .method.post { background: #3b82f6; color: #fff; }
236
+ .method.put { background: #f59e0b; color: #000; }
237
+ .method.delete { background: #ef4444; color: #fff; }
238
+ .method.patch { background: #a855f7; color: #fff; }
239
+
240
+ .path {
241
+ font-family: monospace;
242
+ font-size: 1.1rem;
243
+ }
244
+
245
+ .status {
246
+ padding: 0.2rem 0.6rem;
247
+ border-radius: 4px;
248
+ font-weight: bold;
249
+ font-size: 0.85rem;
250
+ }
251
+
252
+ .status-success { background: #22c55e; color: #000; }
253
+ .status-redirect { background: #f59e0b; color: #000; }
254
+ .status-client-error { background: #ef4444; color: #fff; }
255
+ .status-server-error { background: #dc2626; color: #fff; }
256
+ .status-unknown { background: #6b7280; color: #fff; }
257
+
258
+ .meta {
259
+ display: flex;
260
+ gap: 2rem;
261
+ color: #94a3b8;
262
+ font-size: 0.9rem;
263
+ }
264
+
265
+ .timeline-container {
266
+ background: rgba(255,255,255,0.05);
267
+ border-radius: 8px;
268
+ padding: 1.5rem;
269
+ margin: 1rem 0;
270
+ }
271
+
272
+ .timeline-header {
273
+ display: flex;
274
+ justify-content: space-between;
275
+ color: #64748b;
276
+ font-size: 0.8rem;
277
+ margin-bottom: 0.5rem;
278
+ padding-left: 120px;
279
+ }
280
+
281
+ .timeline-row {
282
+ display: flex;
283
+ align-items: center;
284
+ margin: 0.4rem 0;
285
+ }
286
+
287
+ .timeline-row .label {
288
+ width: 120px;
289
+ font-size: 0.8rem;
290
+ color: #94a3b8;
291
+ overflow: hidden;
292
+ text-overflow: ellipsis;
293
+ white-space: nowrap;
294
+ }
295
+
296
+ .bar-container {
297
+ flex: 1;
298
+ height: 24px;
299
+ background: rgba(255,255,255,0.1);
300
+ border-radius: 4px;
301
+ position: relative;
302
+ }
303
+
304
+ .bar {
305
+ position: absolute;
306
+ height: 100%;
307
+ border-radius: 4px;
308
+ cursor: pointer;
309
+ transition: opacity 0.2s;
310
+ }
311
+
312
+ .bar:hover {
313
+ opacity: 0.8;
314
+ }
315
+
316
+ .bar.success { background: linear-gradient(90deg, #22c55e, #16a34a); }
317
+ .bar.redirect { background: linear-gradient(90deg, #f59e0b, #d97706); }
318
+ .bar.client-error { background: linear-gradient(90deg, #ef4444, #dc2626); }
319
+ .bar.server-error { background: linear-gradient(90deg, #dc2626, #b91c1c); }
320
+ .bar.unknown { background: linear-gradient(90deg, #6b7280, #4b5563); }
321
+ .bar.error { box-shadow: 0 0 8px rgba(239, 68, 68, 0.6); }
322
+
323
+ .main-request .bar {
324
+ opacity: 0.3;
325
+ }
326
+
327
+ .events-list {
328
+ margin-top: 2rem;
329
+ }
330
+
331
+ table {
332
+ width: 100%;
333
+ border-collapse: collapse;
334
+ font-size: 0.9rem;
335
+ }
336
+
337
+ th, td {
338
+ padding: 0.8rem;
339
+ text-align: left;
340
+ border-bottom: 1px solid rgba(255,255,255,0.1);
341
+ }
342
+
343
+ th {
344
+ color: #94a3b8;
345
+ font-weight: 500;
346
+ }
347
+
348
+ tr:hover {
349
+ background: rgba(255,255,255,0.05);
350
+ }
351
+
352
+ footer {
353
+ margin-top: 3rem;
354
+ text-align: center;
355
+ color: #64748b;
356
+ font-size: 0.85rem;
357
+ }
358
+ '''
359
+
360
+
361
+ def _get_js() -> str:
362
+ """Get embedded JavaScript."""
363
+ return '''
364
+ // Interactive features can be added here
365
+ document.querySelectorAll('.bar').forEach(bar => {
366
+ bar.addEventListener('click', () => {
367
+ // Could show detailed popup
368
+ });
369
+ });
370
+ '''
timetracer/types.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ Centralized type definitions for Timetracer.
3
+
4
+ All shared types, protocols, and type aliases are defined here.
5
+ This ensures type consistency across the codebase.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ from timetracer.constants import EventType
12
+
13
+ # =============================================================================
14
+ # REQUEST/RESPONSE SNAPSHOTS
15
+ # =============================================================================
16
+
17
+ @dataclass
18
+ class BodySnapshot:
19
+ """Captured body data with metadata."""
20
+ captured: bool
21
+ encoding: str | None = None # "json", "text", "base64"
22
+ data: Any = None
23
+ truncated: bool = False
24
+ size_bytes: int | None = None
25
+ hash: str | None = None # sha256 hash for matching
26
+
27
+
28
+ @dataclass
29
+ class RequestSnapshot:
30
+ """Captured incoming request data."""
31
+ method: str
32
+ path: str
33
+ route_template: str | None = None
34
+ headers: dict[str, str] = field(default_factory=dict)
35
+ query: dict[str, str] = field(default_factory=dict)
36
+ body: BodySnapshot | None = None
37
+ client_ip: str | None = None
38
+ user_agent: str | None = None
39
+
40
+
41
+ @dataclass
42
+ class ResponseSnapshot:
43
+ """Captured outgoing response data."""
44
+ status: int
45
+ headers: dict[str, str] = field(default_factory=dict)
46
+ body: BodySnapshot | None = None
47
+ duration_ms: float = 0.0
48
+
49
+
50
+ # =============================================================================
51
+ # DEPENDENCY EVENTS
52
+ # =============================================================================
53
+
54
+ @dataclass
55
+ class EventSignature:
56
+ """
57
+ Signature used to match dependency calls during replay.
58
+
59
+ This contains the minimal information needed to identify a call.
60
+ """
61
+ lib: str # e.g., "httpx", "sqlalchemy"
62
+ method: str # HTTP method or operation type
63
+ url: str | None = None # Normalized URL for HTTP
64
+ query: dict[str, str] = field(default_factory=dict)
65
+ headers_hash: str | None = None # Hash of relevant headers
66
+ body_hash: str | None = None # Hash of request body
67
+
68
+
69
+ @dataclass
70
+ class EventResult:
71
+ """Result of a dependency call."""
72
+ status: int | None = None # HTTP status or similar
73
+ headers: dict[str, str] = field(default_factory=dict)
74
+ body: BodySnapshot | None = None
75
+ error: str | None = None
76
+ error_type: str | None = None
77
+
78
+
79
+ @dataclass
80
+ class DependencyEvent:
81
+ """
82
+ A captured dependency call (HTTP, DB, etc.).
83
+
84
+ This is the core unit stored in cassettes.
85
+ """
86
+ eid: int # Sequential event ID within the session
87
+ event_type: EventType
88
+ start_offset_ms: float # Time since request start
89
+ duration_ms: float
90
+ signature: EventSignature
91
+ result: EventResult
92
+
93
+
94
+ # =============================================================================
95
+ # SESSION METADATA
96
+ # =============================================================================
97
+
98
+ @dataclass
99
+ class SessionMeta:
100
+ """Metadata about the recording session."""
101
+ id: str # UUID
102
+ recorded_at: str # ISO-8601 timestamp
103
+ service: str
104
+ env: str
105
+ framework: str = "fastapi"
106
+ timetracer_version: str = ""
107
+ python_version: str = ""
108
+ git_sha: str | None = None
109
+
110
+
111
+ @dataclass
112
+ class CaptureStats:
113
+ """Statistics about what was captured."""
114
+ event_counts: dict[str, int] = field(default_factory=dict)
115
+ total_events: int = 0
116
+ total_duration_ms: float = 0.0
117
+
118
+
119
+ @dataclass
120
+ class AppliedPolicies:
121
+ """Record of which policies were applied during capture."""
122
+ redaction_mode: str = "default"
123
+ redaction_rules: list[str] = field(default_factory=list)
124
+ max_body_kb: int = 64
125
+ store_request_body: str = "on_error"
126
+ store_response_body: str = "on_error"
127
+ sample_rate: float = 1.0
128
+ errors_only: bool = False
129
+
130
+
131
+ # =============================================================================
132
+ # CASSETTE (TOP-LEVEL STRUCTURE)
133
+ # =============================================================================
134
+
135
+ @dataclass
136
+ class Cassette:
137
+ """
138
+ Complete cassette structure.
139
+
140
+ This is the portable artifact that gets saved/loaded.
141
+ """
142
+ schema_version: str
143
+ session: SessionMeta
144
+ request: RequestSnapshot
145
+ response: ResponseSnapshot
146
+ events: list[DependencyEvent] = field(default_factory=list)
147
+ policies: AppliedPolicies = field(default_factory=AppliedPolicies)
148
+ stats: CaptureStats = field(default_factory=CaptureStats)
149
+ error_info: dict[str, Any] | None = None # Stack trace, error type, message
150
+
151
+
152
+ # =============================================================================
153
+ # PLUGIN PROTOCOL
154
+ # =============================================================================
155
+
156
+ @runtime_checkable
157
+ class TracePlugin(Protocol):
158
+ """
159
+ Protocol for Timetracer plugins.
160
+
161
+ Plugins must implement this interface to integrate with the system.
162
+ """
163
+
164
+ @property
165
+ def name(self) -> str:
166
+ """Unique plugin identifier."""
167
+ ...
168
+
169
+ @property
170
+ def event_type(self) -> EventType:
171
+ """The type of events this plugin captures."""
172
+ ...
173
+
174
+ def setup(self, config: Any) -> None:
175
+ """Initialize the plugin with configuration."""
176
+ ...
177
+
178
+ def enable_recording(self) -> None:
179
+ """Start capturing events."""
180
+ ...
181
+
182
+ def enable_replay(self) -> None:
183
+ """Start mocking calls with recorded data."""
184
+ ...
185
+
186
+ def disable(self) -> None:
187
+ """Stop capturing/mocking and restore original behavior."""
188
+ ...
189
+
190
+
191
+ # =============================================================================
192
+ # TYPE ALIASES
193
+ # =============================================================================
194
+
195
+ HeadersDict = dict[str, str]
196
+ QueryDict = dict[str, str]
197
+ JsonDict = dict[str, Any]
@@ -0,0 +1,6 @@
1
+ """Utility modules for Timetracer."""
2
+
3
+ from timetracer.utils.hashing import hash_body, hash_string
4
+ from timetracer.utils.time import Timer
5
+
6
+ __all__ = ["hash_body", "hash_string", "Timer"]
@@ -0,0 +1,68 @@
1
+ """
2
+ Hashing utilities for body matching.
3
+
4
+ Provides consistent hashing for signature comparison during replay.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ from typing import Any
10
+
11
+
12
+ def hash_body(data: bytes | str | Any) -> str:
13
+ """
14
+ Create a stable hash of body data.
15
+
16
+ Args:
17
+ data: Body data as bytes, string, or JSON-serializable object.
18
+
19
+ Returns:
20
+ SHA-256 hash prefixed with "sha256:".
21
+ """
22
+ if data is None:
23
+ return "sha256:none"
24
+
25
+ # Convert to bytes
26
+ if isinstance(data, str):
27
+ data_bytes = data.encode("utf-8")
28
+ elif isinstance(data, bytes):
29
+ data_bytes = data
30
+ else:
31
+ # JSON serialize for objects
32
+ try:
33
+ data_bytes = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
34
+ except (TypeError, ValueError):
35
+ data_bytes = str(data).encode("utf-8")
36
+
37
+ hash_value = hashlib.sha256(data_bytes).hexdigest()
38
+ return f"sha256:{hash_value}"
39
+
40
+
41
+ def hash_string(value: str) -> str:
42
+ """
43
+ Create a hash of a string value.
44
+
45
+ Args:
46
+ value: String to hash.
47
+
48
+ Returns:
49
+ SHA-256 hash prefixed with "sha256:".
50
+ """
51
+ hash_value = hashlib.sha256(value.encode("utf-8")).hexdigest()
52
+ return f"sha256:{hash_value}"
53
+
54
+
55
+ def short_hash(data: bytes | str | Any, length: int = 8) -> str:
56
+ """
57
+ Create a short hash for display purposes.
58
+
59
+ Args:
60
+ data: Data to hash.
61
+ length: Number of characters to return.
62
+
63
+ Returns:
64
+ Truncated hash without prefix.
65
+ """
66
+ full_hash = hash_body(data)
67
+ # Remove "sha256:" prefix and truncate
68
+ return full_hash.split(":")[1][:length]