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
|
@@ -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} Start: {event.start_ms:.1f}ms Duration: {event.duration_ms:.1f}ms 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,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]
|