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
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"]
|