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,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
|