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,537 @@
1
+ """
2
+ FastAPI middleware for Timetracer.
3
+
4
+ This is the main integration point for FastAPI 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
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, should_store_body
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 starlette.types import ASGIApp, Message, Receive, Scope, Send
25
+
26
+
27
+ class TimeTraceMiddleware:
28
+ """
29
+ ASGI middleware for Timetracer integration.
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 fastapi import FastAPI
40
+ from timetracer.integrations.fastapi import timetracerMiddleware
41
+ from timetracer.config import TraceConfig
42
+
43
+ app = FastAPI()
44
+ config = TraceConfig(mode="record", cassette_dir="./cassettes")
45
+ app.add_middleware(TimeTraceMiddleware, config=config)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ app: ASGIApp,
51
+ config: TraceConfig | None = None,
52
+ ) -> None:
53
+ """
54
+ Initialize middleware.
55
+
56
+ Args:
57
+ app: The ASGI 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
+ async def __call__(
64
+ self,
65
+ scope: Scope,
66
+ receive: Receive,
67
+ send: Send,
68
+ ) -> None:
69
+ """Handle ASGI request."""
70
+ # Only process HTTP requests
71
+ if scope["type"] != "http":
72
+ await self.app(scope, receive, send)
73
+ return
74
+
75
+ # Check if timetrace is enabled
76
+ if not self.config.is_enabled:
77
+ await self.app(scope, receive, send)
78
+ return
79
+
80
+ # Get request path
81
+ path = scope.get("path", "/")
82
+
83
+ # Check if path should be traced
84
+ if not self.config.should_trace(path):
85
+ await self.app(scope, receive, send)
86
+ return
87
+
88
+ # Check sampling (only for record mode)
89
+ if self.config.is_record_mode and not self.config.should_sample():
90
+ await self.app(scope, receive, send)
91
+ return
92
+
93
+ # Route to appropriate handler
94
+ if self.config.is_record_mode:
95
+ await self._handle_record(scope, receive, send)
96
+ elif self.config.is_replay_mode:
97
+ await self._handle_replay(scope, receive, send)
98
+ else:
99
+ await self.app(scope, receive, send)
100
+
101
+ async def _handle_record(
102
+ self,
103
+ scope: Scope,
104
+ receive: Receive,
105
+ send: Send,
106
+ ) -> None:
107
+ """Handle request in record mode."""
108
+ # Create session
109
+ session = TraceSession(config=self.config)
110
+ token = set_session(session)
111
+
112
+ start_time = time.perf_counter()
113
+
114
+ try:
115
+ # Capture request
116
+ request_snapshot = await self._capture_request(scope, receive)
117
+ session.set_request(request_snapshot)
118
+
119
+ # Create a new receive that returns already-read body
120
+ body_bytes = b""
121
+ if request_snapshot.body and request_snapshot.body.data:
122
+ if request_snapshot.body.encoding == "json":
123
+ body_bytes = json.dumps(request_snapshot.body.data).encode()
124
+ elif isinstance(request_snapshot.body.data, str):
125
+ body_bytes = request_snapshot.body.data.encode()
126
+ elif isinstance(request_snapshot.body.data, bytes):
127
+ body_bytes = request_snapshot.body.data
128
+
129
+ # Track response
130
+ response_started = False
131
+ response_status = 0
132
+ response_headers: dict[str, str] = {}
133
+ response_body_parts: list[bytes] = []
134
+
135
+ async def send_wrapper(message: Message) -> None:
136
+ nonlocal response_started, response_status, response_headers, response_body_parts
137
+
138
+ if message["type"] == "http.response.start":
139
+ response_started = True
140
+ response_status = message.get("status", 0)
141
+ headers = message.get("headers", [])
142
+ response_headers = {
143
+ k.decode() if isinstance(k, bytes) else k:
144
+ v.decode() if isinstance(v, bytes) else v
145
+ for k, v in headers
146
+ }
147
+
148
+ elif message["type"] == "http.response.body":
149
+ body = message.get("body", b"")
150
+ if body:
151
+ response_body_parts.append(body)
152
+
153
+ await send(message)
154
+
155
+ # Create receive wrapper if we consumed the body
156
+ body_consumed = False
157
+
158
+ async def receive_wrapper() -> Message:
159
+ nonlocal body_consumed
160
+ if not body_consumed and body_bytes:
161
+ body_consumed = True
162
+ return {
163
+ "type": "http.request",
164
+ "body": body_bytes,
165
+ "more_body": False,
166
+ }
167
+ return await receive()
168
+
169
+ # Call the app
170
+ is_error = False
171
+ try:
172
+ await self.app(scope, receive_wrapper, send_wrapper)
173
+ except Exception as e:
174
+ is_error = True
175
+ session.mark_error(
176
+ error_type=type(e).__name__,
177
+ error_message=str(e),
178
+ )
179
+ raise
180
+ finally:
181
+ # Calculate duration
182
+ duration_ms = (time.perf_counter() - start_time) * 1000
183
+
184
+ # Capture response
185
+ is_error = is_error or response_status >= 400
186
+ response_body = b"".join(response_body_parts)
187
+
188
+ response_snapshot = self._build_response_snapshot(
189
+ status=response_status,
190
+ headers=response_headers,
191
+ body=response_body,
192
+ duration_ms=duration_ms,
193
+ is_error=is_error,
194
+ )
195
+ session.set_response(response_snapshot)
196
+
197
+ # Finalize and write cassette
198
+ session.finalize()
199
+
200
+ # Only write if errors_only is False, or if there was an error
201
+ if not self.config.errors_only or is_error:
202
+ cassette_path = write_cassette(session, self.config)
203
+ self._print_record_summary(session, cassette_path)
204
+
205
+ finally:
206
+ reset_session(token)
207
+
208
+ async def _handle_replay(
209
+ self,
210
+ scope: Scope,
211
+ receive: Receive,
212
+ send: Send,
213
+ ) -> None:
214
+ """Handle request in replay mode."""
215
+ # Load cassette
216
+ cassette_path = self.config.cassette_path
217
+ if not cassette_path:
218
+ # Try to find matching cassette by path
219
+ # For now, require explicit cassette_path
220
+ print("timetracer [WARN] replay mode requires TIMETRACER_CASSETTE", file=sys.stderr)
221
+ await self.app(scope, receive, send)
222
+ return
223
+
224
+ cassette = read_cassette(cassette_path)
225
+
226
+ # Create replay session
227
+ session = ReplaySession(
228
+ cassette=cassette,
229
+ cassette_path=cassette_path,
230
+ strict=self.config.strict_replay,
231
+ config=self.config, # Pass config for hybrid replay
232
+ )
233
+ token = set_session(session)
234
+
235
+ start_time = time.perf_counter()
236
+
237
+ try:
238
+ # Run the app (plugins will intercept dependency calls)
239
+ await self.app(scope, receive, send)
240
+
241
+ duration_ms = (time.perf_counter() - start_time) * 1000
242
+ self._print_replay_summary(session, duration_ms)
243
+
244
+ finally:
245
+ reset_session(token)
246
+
247
+ async def _capture_request(
248
+ self,
249
+ scope: Scope,
250
+ receive: Receive,
251
+ ) -> RequestSnapshot:
252
+ """Capture incoming request data."""
253
+ method = scope.get("method", "GET")
254
+ path = scope.get("path", "/")
255
+
256
+ # Get route template from scope (set by Starlette/FastAPI)
257
+ route_template = None
258
+ if "route" in scope:
259
+ route = scope["route"]
260
+ if hasattr(route, "path"):
261
+ route_template = route.path
262
+
263
+ # Headers
264
+ raw_headers = scope.get("headers", [])
265
+ headers = {
266
+ k.decode() if isinstance(k, bytes) else k:
267
+ v.decode() if isinstance(v, bytes) else v
268
+ for k, v in raw_headers
269
+ }
270
+
271
+ # Redact sensitive headers
272
+ headers = redact_headers(headers)
273
+
274
+ # Query params
275
+ query_string = scope.get("query_string", b"")
276
+ query = self._parse_query_string(query_string)
277
+
278
+ # Client info
279
+ client = scope.get("client")
280
+ client_ip = client[0] if client else None
281
+ user_agent = headers.get("user-agent")
282
+
283
+ # Body
284
+ body_snapshot = await self._capture_request_body(receive)
285
+
286
+ return RequestSnapshot(
287
+ method=method,
288
+ path=path,
289
+ route_template=route_template,
290
+ headers=headers,
291
+ query=query,
292
+ body=body_snapshot,
293
+ client_ip=client_ip,
294
+ user_agent=user_agent,
295
+ )
296
+
297
+ async def _capture_request_body(
298
+ self,
299
+ receive: Receive,
300
+ ) -> BodySnapshot | None:
301
+ """Capture request body data."""
302
+ # Always read the body (we need it for the app)
303
+ body_parts: list[bytes] = []
304
+
305
+ while True:
306
+ message = await receive()
307
+ if message["type"] == "http.request":
308
+ body = message.get("body", b"")
309
+ if body:
310
+ body_parts.append(body)
311
+ if not message.get("more_body", False):
312
+ break
313
+ elif message["type"] == "http.disconnect":
314
+ break
315
+
316
+ full_body = b"".join(body_parts)
317
+
318
+ if not full_body:
319
+ return None
320
+
321
+ # Check size
322
+ size_bytes = len(full_body)
323
+ max_bytes = self.config.max_body_kb * 1024
324
+ truncated = size_bytes > max_bytes
325
+
326
+ if truncated:
327
+ full_body = full_body[:max_bytes]
328
+
329
+ # Try to parse as JSON
330
+ encoding = "bytes"
331
+ data: Any = None
332
+
333
+ try:
334
+ data = json.loads(full_body.decode("utf-8"))
335
+ encoding = "json"
336
+ # Redact sensitive data
337
+ data = redact_body(data)
338
+ except (json.JSONDecodeError, UnicodeDecodeError):
339
+ # Store as base64 or hash only depending on policy
340
+ encoding = "bytes"
341
+ data = None
342
+
343
+ return BodySnapshot(
344
+ captured=True,
345
+ encoding=encoding,
346
+ data=data,
347
+ truncated=truncated,
348
+ size_bytes=size_bytes,
349
+ hash=hash_body(full_body),
350
+ )
351
+
352
+ def _build_response_snapshot(
353
+ self,
354
+ status: int,
355
+ headers: dict[str, str],
356
+ body: bytes,
357
+ duration_ms: float,
358
+ is_error: bool,
359
+ ) -> ResponseSnapshot:
360
+ """Build response snapshot with policy-based capture."""
361
+ # Redact headers
362
+ headers = redact_headers(headers)
363
+
364
+ # Check if we should store body
365
+ should_store = should_store_body(
366
+ self.config.store_response_body,
367
+ is_error=is_error,
368
+ )
369
+
370
+ body_snapshot = None
371
+ if should_store and body:
372
+ size_bytes = len(body)
373
+ max_bytes = self.config.max_body_kb * 1024
374
+ truncated = size_bytes > max_bytes
375
+
376
+ if truncated:
377
+ body = body[:max_bytes]
378
+
379
+ # Try to parse as JSON
380
+ encoding = "bytes"
381
+ data: Any = None
382
+
383
+ try:
384
+ data = json.loads(body.decode("utf-8"))
385
+ encoding = "json"
386
+ data = redact_body(data)
387
+ except (json.JSONDecodeError, UnicodeDecodeError):
388
+ encoding = "bytes"
389
+ data = None
390
+
391
+ body_snapshot = BodySnapshot(
392
+ captured=True,
393
+ encoding=encoding,
394
+ data=data,
395
+ truncated=truncated,
396
+ size_bytes=size_bytes,
397
+ hash=hash_body(body),
398
+ )
399
+ elif body:
400
+ # Just store hash
401
+ body_snapshot = BodySnapshot(
402
+ captured=False,
403
+ hash=hash_body(body),
404
+ size_bytes=len(body),
405
+ )
406
+
407
+ return ResponseSnapshot(
408
+ status=status,
409
+ headers=headers,
410
+ body=body_snapshot,
411
+ duration_ms=duration_ms,
412
+ )
413
+
414
+ def _parse_query_string(self, query_string: bytes) -> dict[str, str]:
415
+ """Parse query string into dict."""
416
+ if not query_string:
417
+ return {}
418
+
419
+ try:
420
+ from urllib.parse import parse_qs
421
+ qs = query_string.decode("utf-8")
422
+ parsed = parse_qs(qs)
423
+ # Flatten to single values (take first)
424
+ return {k: v[0] if v else "" for k, v in parsed.items()}
425
+ except Exception:
426
+ return {}
427
+
428
+ def _print_record_summary(self, session: TraceSession, cassette_path: str) -> None:
429
+ """Print terminal summary for record mode."""
430
+ req = session.request
431
+ res = session.response
432
+
433
+ method = req.method if req else "???"
434
+ path = req.path if req else "???"
435
+ status = res.status if res else 0
436
+ duration_ms = res.duration_ms if res else 0
437
+
438
+ # Event counts
439
+ event_counts = {}
440
+ for event in session.events:
441
+ et = event.event_type.value
442
+ event_counts[et] = event_counts.get(et, 0) + 1
443
+
444
+ deps_str = ", ".join(f"{k}:{v}" for k, v in event_counts.items()) or "none"
445
+
446
+ # Status icon
447
+ icon = "[OK]" if status < 400 else "[WARN]"
448
+
449
+ print(
450
+ f"timetracer {icon} recorded {method} {path} "
451
+ f"id={session.short_id} status={status} "
452
+ f"total={duration_ms:.0f}ms deps={deps_str}",
453
+ file=sys.stderr,
454
+ )
455
+ print(f" cassette: {cassette_path}", file=sys.stderr)
456
+
457
+ def _print_replay_summary(self, session: ReplaySession, duration_ms: float) -> None:
458
+ """Print terminal summary for replay mode."""
459
+ req = session.request
460
+ method = req.method
461
+ path = req.path
462
+
463
+ recorded_duration = session.cassette.response.duration_ms
464
+
465
+ # Check for unconsumed events
466
+ unconsumed = len(session.get_unconsumed_events())
467
+ match_status = "OK" if unconsumed == 0 else f"WARN ({unconsumed} unconsumed)"
468
+
469
+ # Mocked counts
470
+ mocked_count = session.current_cursor
471
+
472
+ print(
473
+ f"timetracer replay {method} {path} "
474
+ f"mocked={mocked_count} matched={match_status} "
475
+ f"runtime={duration_ms:.0f}ms recorded={recorded_duration:.0f}ms",
476
+ file=sys.stderr,
477
+ )
478
+
479
+
480
+ def auto_setup(
481
+ app: Any,
482
+ config: TraceConfig | None = None,
483
+ plugins: list[str] | None = None,
484
+ ) -> Any:
485
+ """
486
+ One-line Timetracer setup for FastAPI.
487
+
488
+ Adds middleware and enables plugins automatically.
489
+
490
+ Args:
491
+ app: FastAPI application instance.
492
+ config: Optional TraceConfig. If None, loads from environment.
493
+ plugins: List of plugins to enable. Default: ["httpx"].
494
+ Options: "httpx", "requests", "sqlalchemy", "redis"
495
+
496
+ Returns:
497
+ The app instance (for chaining).
498
+
499
+ Usage:
500
+ from fastapi import FastAPI
501
+ from timetracer.integrations.fastapi import auto_setup
502
+
503
+ app = auto_setup(FastAPI())
504
+
505
+ # Or with options:
506
+ app = FastAPI()
507
+ auto_setup(app, plugins=["httpx", "redis"])
508
+ """
509
+ from timetracer.plugins import enable_httpx
510
+
511
+ cfg = config or TraceConfig.from_env()
512
+
513
+ # Add middleware
514
+ app.add_middleware(TimeTraceMiddleware, config=cfg)
515
+
516
+ # Enable plugins
517
+ enabled_plugins = plugins or ["httpx"]
518
+
519
+ for plugin in enabled_plugins:
520
+ if plugin == "httpx":
521
+ from timetracer.plugins import enable_httpx
522
+ enable_httpx()
523
+ elif plugin == "requests":
524
+ from timetracer.plugins import enable_requests
525
+ enable_requests()
526
+ elif plugin == "sqlalchemy":
527
+ from timetracer.plugins import enable_sqlalchemy
528
+ enable_sqlalchemy()
529
+ elif plugin == "redis":
530
+ from timetracer.plugins import enable_redis
531
+ enable_redis()
532
+
533
+ return app
534
+
535
+
536
+ # Backwards compatibility alias
537
+ timetracerMiddleware = TimeTraceMiddleware