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
timetracer/session.py ADDED
@@ -0,0 +1,390 @@
1
+ """
2
+ Session management for Timetracer.
3
+
4
+ Sessions hold all captured data for a single request lifecycle.
5
+ - TraceSession: Used in record mode to collect events
6
+ - ReplaySession: Used in replay mode to serve recorded events
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import uuid
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from timetracer import __version__
19
+ from timetracer.constants import SCHEMA_VERSION, EventType, TraceMode
20
+ from timetracer.exceptions import ReplayMismatchError
21
+ from timetracer.types import (
22
+ AppliedPolicies,
23
+ CaptureStats,
24
+ Cassette,
25
+ DependencyEvent,
26
+ RequestSnapshot,
27
+ ResponseSnapshot,
28
+ SessionMeta,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from timetracer.config import TraceConfig
33
+
34
+
35
+ class BaseSession(ABC):
36
+ """
37
+ Abstract base class for all session types.
38
+
39
+ This provides a common interface for both record and replay modes.
40
+ """
41
+
42
+ @property
43
+ @abstractmethod
44
+ def mode(self) -> TraceMode:
45
+ """The mode this session operates in."""
46
+ ...
47
+
48
+ @property
49
+ @abstractmethod
50
+ def session_id(self) -> str:
51
+ """Unique identifier for this session."""
52
+ ...
53
+
54
+ @property
55
+ @abstractmethod
56
+ def is_recording(self) -> bool:
57
+ """True if this session is recording events."""
58
+ ...
59
+
60
+ @property
61
+ @abstractmethod
62
+ def is_replaying(self) -> bool:
63
+ """True if this session is replaying from cassette."""
64
+ ...
65
+
66
+
67
+ @dataclass
68
+ class TraceSession(BaseSession):
69
+ """
70
+ Session for record mode.
71
+
72
+ Collects request/response data and dependency events during execution.
73
+ Finalized into a Cassette for storage.
74
+ """
75
+
76
+ config: TraceConfig
77
+
78
+ # Session identity
79
+ _session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
80
+ _start_time: float = field(default_factory=time.perf_counter)
81
+ _start_timestamp: str = field(
82
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
83
+ )
84
+
85
+ # Captured data
86
+ request: RequestSnapshot | None = None
87
+ response: ResponseSnapshot | None = None
88
+ events: list[DependencyEvent] = field(default_factory=list)
89
+
90
+ # State tracking
91
+ _event_counter: int = 0
92
+ _is_error: bool = False
93
+ _error_info: dict[str, Any] | None = None
94
+ _finalized: bool = False
95
+
96
+ @property
97
+ def mode(self) -> TraceMode:
98
+ return TraceMode.RECORD
99
+
100
+ @property
101
+ def session_id(self) -> str:
102
+ return self._session_id
103
+
104
+ @property
105
+ def short_id(self) -> str:
106
+ """Short version of session ID for filenames."""
107
+ return self._session_id[:8]
108
+
109
+ @property
110
+ def is_recording(self) -> bool:
111
+ return True
112
+
113
+ @property
114
+ def is_replaying(self) -> bool:
115
+ return False
116
+
117
+ @property
118
+ def elapsed_ms(self) -> float:
119
+ """Milliseconds since session started."""
120
+ return (time.perf_counter() - self._start_time) * 1000
121
+
122
+ def set_request(self, request: RequestSnapshot) -> None:
123
+ """Set the captured request data."""
124
+ self.request = request
125
+
126
+ def set_response(self, response: ResponseSnapshot) -> None:
127
+ """Set the captured response data."""
128
+ self.response = response
129
+
130
+ def add_event(self, event: DependencyEvent) -> None:
131
+ """
132
+ Add a captured dependency event.
133
+
134
+ Events are automatically assigned sequential IDs.
135
+ """
136
+ if self._finalized:
137
+ raise RuntimeError("Cannot add events to a finalized session")
138
+
139
+ # Assign event ID
140
+ self._event_counter += 1
141
+ event.eid = self._event_counter
142
+
143
+ self.events.append(event)
144
+
145
+ def mark_error(
146
+ self,
147
+ error_type: str,
148
+ error_message: str,
149
+ traceback: str | None = None
150
+ ) -> None:
151
+ """Mark session as having an error."""
152
+ self._is_error = True
153
+ self._error_info = {
154
+ "type": error_type,
155
+ "message": error_message,
156
+ }
157
+ if traceback:
158
+ self._error_info["traceback"] = traceback
159
+
160
+ @property
161
+ def has_error(self) -> bool:
162
+ """Check if this session has an error."""
163
+ return self._is_error
164
+
165
+ def finalize(self) -> None:
166
+ """Mark session as complete. No more events can be added."""
167
+ self._finalized = True
168
+
169
+ def to_cassette(self) -> Cassette:
170
+ """
171
+ Convert session to a Cassette for storage.
172
+
173
+ This should be called after finalize().
174
+ """
175
+ # Build session metadata
176
+ session_meta = SessionMeta(
177
+ id=self._session_id,
178
+ recorded_at=self._start_timestamp,
179
+ service=self.config.service_name,
180
+ env=self.config.env,
181
+ framework="fastapi",
182
+ timetracer_version=__version__,
183
+ python_version=self.config.get_python_version(),
184
+ )
185
+
186
+ # Build stats
187
+ event_counts: dict[str, int] = {}
188
+ for event in self.events:
189
+ event_type_str = event.event_type.value
190
+ event_counts[event_type_str] = event_counts.get(event_type_str, 0) + 1
191
+
192
+ stats = CaptureStats(
193
+ event_counts=event_counts,
194
+ total_events=len(self.events),
195
+ total_duration_ms=self.response.duration_ms if self.response else self.elapsed_ms,
196
+ )
197
+
198
+ # Build applied policies
199
+ policies = AppliedPolicies(
200
+ redaction_mode="default",
201
+ redaction_rules=["authorization", "cookie"],
202
+ max_body_kb=self.config.max_body_kb,
203
+ store_request_body=self.config.store_request_body.value,
204
+ store_response_body=self.config.store_response_body.value,
205
+ sample_rate=self.config.sample_rate,
206
+ errors_only=self.config.errors_only,
207
+ )
208
+
209
+ return Cassette(
210
+ schema_version=SCHEMA_VERSION,
211
+ session=session_meta,
212
+ request=self.request or RequestSnapshot(method="", path=""),
213
+ response=self.response or ResponseSnapshot(status=0),
214
+ events=self.events,
215
+ policies=policies,
216
+ stats=stats,
217
+ error_info=self._error_info if self._is_error else None,
218
+ )
219
+
220
+
221
+ @dataclass
222
+ class ReplaySession(BaseSession):
223
+ """
224
+ Session for replay mode.
225
+
226
+ Loads a cassette and serves recorded events during execution.
227
+ Uses order-first matching to return expected responses.
228
+ """
229
+
230
+ cassette: Cassette
231
+ cassette_path: str
232
+ strict: bool = True
233
+ config: TraceConfig | None = None # For hybrid replay
234
+
235
+ # Replay cursor
236
+ _cursor: int = 0
237
+ _consumed_events: list[int] = field(default_factory=list)
238
+
239
+ @property
240
+ def mode(self) -> TraceMode:
241
+ return TraceMode.REPLAY
242
+
243
+ @property
244
+ def session_id(self) -> str:
245
+ return self.cassette.session.id
246
+
247
+ @property
248
+ def is_recording(self) -> bool:
249
+ return False
250
+
251
+ @property
252
+ def is_replaying(self) -> bool:
253
+ return True
254
+
255
+ @property
256
+ def request(self) -> RequestSnapshot:
257
+ """The recorded request."""
258
+ return self.cassette.request
259
+
260
+ @property
261
+ def events(self) -> list[DependencyEvent]:
262
+ """All recorded events."""
263
+ return self.cassette.events
264
+
265
+ def should_mock_plugin(self, plugin_name: str) -> bool:
266
+ """
267
+ Check if a plugin should be mocked in this replay session.
268
+
269
+ This enables hybrid replay where some dependencies are mocked
270
+ and others are kept live.
271
+
272
+ Args:
273
+ plugin_name: The plugin name (e.g., "http", "db")
274
+
275
+ Returns:
276
+ True if the plugin should be mocked, False to keep live.
277
+ """
278
+ if self.config is None:
279
+ return True # Default: mock everything
280
+ return self.config.should_mock_plugin(plugin_name)
281
+
282
+ @property
283
+ def current_cursor(self) -> int:
284
+ """Current position in event list."""
285
+ return self._cursor
286
+
287
+ @property
288
+ def has_more_events(self) -> bool:
289
+ """Check if there are unmatched events remaining."""
290
+ return self._cursor < len(self.cassette.events)
291
+
292
+ def peek_next_event(self, event_type: EventType | None = None) -> DependencyEvent | None:
293
+ """
294
+ Look at the next expected event without consuming it.
295
+
296
+ Args:
297
+ event_type: Optionally filter by event type.
298
+
299
+ Returns:
300
+ The next event, or None if no matching event.
301
+ """
302
+ if self._cursor >= len(self.cassette.events):
303
+ return None
304
+
305
+ event = self.cassette.events[self._cursor]
306
+
307
+ if event_type is not None and event.event_type != event_type:
308
+ return None
309
+
310
+ return event
311
+
312
+ def get_next_event(
313
+ self,
314
+ event_type: EventType,
315
+ actual_signature: dict[str, Any],
316
+ ) -> DependencyEvent:
317
+ """
318
+ Get the next expected event for matching.
319
+
320
+ This validates that the actual call matches the expected call.
321
+ Raises ReplayMismatchError if there's a mismatch and strict=True.
322
+
323
+ Args:
324
+ event_type: Expected event type.
325
+ actual_signature: Signature of the actual call being made.
326
+
327
+ Returns:
328
+ The matched event from the cassette.
329
+
330
+ Raises:
331
+ ReplayMismatchError: If call doesn't match expected (in strict mode).
332
+ """
333
+ if self._cursor >= len(self.cassette.events):
334
+ if self.strict:
335
+ raise ReplayMismatchError(
336
+ f"Unexpected {event_type.value} call: no more events in cassette",
337
+ cassette_path=self.cassette_path,
338
+ endpoint=f"{self.cassette.request.method} {self.cassette.request.path}",
339
+ event_index=self._cursor,
340
+ actual=actual_signature,
341
+ hint="Your code is making more dependency calls than recorded. Re-record the cassette.",
342
+ )
343
+ return None # type: ignore
344
+
345
+ expected = self.cassette.events[self._cursor]
346
+
347
+ # Validate event type
348
+ if expected.event_type != event_type:
349
+ if self.strict:
350
+ raise ReplayMismatchError(
351
+ f"Event type mismatch at event #{self._cursor}",
352
+ cassette_path=self.cassette_path,
353
+ endpoint=f"{self.cassette.request.method} {self.cassette.request.path}",
354
+ event_index=self._cursor,
355
+ expected={"type": expected.event_type.value},
356
+ actual={"type": event_type.value},
357
+ hint="Call order or type has changed. Re-record the cassette.",
358
+ )
359
+ return None # type: ignore
360
+
361
+ # Validate signature (basic matching)
362
+ expected_sig = expected.signature
363
+ mismatches: dict[str, tuple[Any, Any]] = {}
364
+
365
+ if "method" in actual_signature and expected_sig.method != actual_signature["method"]:
366
+ mismatches["method"] = (expected_sig.method, actual_signature["method"])
367
+
368
+ if "url" in actual_signature and expected_sig.url != actual_signature.get("url"):
369
+ mismatches["url"] = (expected_sig.url, actual_signature.get("url"))
370
+
371
+ if mismatches and self.strict:
372
+ raise ReplayMismatchError(
373
+ f"{event_type.value} mismatch at event #{self._cursor}",
374
+ cassette_path=self.cassette_path,
375
+ endpoint=f"{self.cassette.request.method} {self.cassette.request.path}",
376
+ event_index=self._cursor,
377
+ expected={k: v[0] for k, v in mismatches.items()},
378
+ actual={k: v[1] for k, v in mismatches.items()},
379
+ hint="Your dependency call changed (endpoint/method). Re-record cassette or disable strict replay.",
380
+ )
381
+
382
+ # Consume the event
383
+ self._consumed_events.append(self._cursor)
384
+ self._cursor += 1
385
+
386
+ return expected
387
+
388
+ def get_unconsumed_events(self) -> list[DependencyEvent]:
389
+ """Get list of events that weren't matched during replay."""
390
+ return self.cassette.events[self._cursor:]
@@ -0,0 +1,18 @@
1
+ """
2
+ Storage backends for Timetracer cassettes.
3
+
4
+ Provides pluggable storage options:
5
+ - Local filesystem (default)
6
+ - S3 (AWS, MinIO, etc.)
7
+ """
8
+
9
+ # S3 is optional
10
+ try:
11
+ from timetracer.storage.s3 import S3Config, S3Store
12
+ _HAS_S3 = True
13
+ except ImportError:
14
+ _HAS_S3 = False
15
+ S3Store = None # type: ignore
16
+ S3Config = None # type: ignore
17
+
18
+ __all__ = ["S3Store", "S3Config"]