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,507 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask integration for Timetracer.
|
|
3
|
+
|
|
4
|
+
This is the main integration point for Flask 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, Callable
|
|
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
|
|
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 flask import Flask
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TimeTraceMiddleware:
|
|
28
|
+
"""
|
|
29
|
+
WSGI middleware for Timetracer integration with Flask.
|
|
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 flask import Flask
|
|
40
|
+
from timetracer.integrations.flask import timetracerMiddleware
|
|
41
|
+
from timetracer.config import TraceConfig
|
|
42
|
+
|
|
43
|
+
app = Flask(__name__)
|
|
44
|
+
config = TraceConfig(mode="record", cassette_dir="./cassettes")
|
|
45
|
+
app.wsgi_app = TimeTraceMiddleware(app.wsgi_app, config=config)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
app: Any, # WSGI app
|
|
51
|
+
config: TraceConfig | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Initialize middleware.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
app: The WSGI 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
|
+
def __call__(
|
|
64
|
+
self,
|
|
65
|
+
environ: dict[str, Any],
|
|
66
|
+
start_response: Callable,
|
|
67
|
+
) -> Any:
|
|
68
|
+
"""Handle WSGI request."""
|
|
69
|
+
# Check if timetrace is enabled
|
|
70
|
+
if not self.config.is_enabled:
|
|
71
|
+
return self.app(environ, start_response)
|
|
72
|
+
|
|
73
|
+
# Get request path
|
|
74
|
+
path = environ.get("PATH_INFO", "/")
|
|
75
|
+
|
|
76
|
+
# Check if path should be traced
|
|
77
|
+
if not self.config.should_trace(path):
|
|
78
|
+
return self.app(environ, start_response)
|
|
79
|
+
|
|
80
|
+
# Check sampling (only for record mode)
|
|
81
|
+
if self.config.is_record_mode and not self.config.should_sample():
|
|
82
|
+
return self.app(environ, start_response)
|
|
83
|
+
|
|
84
|
+
# Route to appropriate handler
|
|
85
|
+
if self.config.is_record_mode:
|
|
86
|
+
return self._handle_record(environ, start_response)
|
|
87
|
+
elif self.config.is_replay_mode:
|
|
88
|
+
return self._handle_replay(environ, start_response)
|
|
89
|
+
else:
|
|
90
|
+
return self.app(environ, start_response)
|
|
91
|
+
|
|
92
|
+
def _handle_record(
|
|
93
|
+
self,
|
|
94
|
+
environ: dict[str, Any],
|
|
95
|
+
start_response: Callable,
|
|
96
|
+
) -> Any:
|
|
97
|
+
"""Handle request in record mode."""
|
|
98
|
+
# Create session
|
|
99
|
+
session = TraceSession(config=self.config)
|
|
100
|
+
token = set_session(session)
|
|
101
|
+
|
|
102
|
+
start_time = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Capture request
|
|
106
|
+
request_snapshot = self._capture_request(environ)
|
|
107
|
+
session.set_request(request_snapshot)
|
|
108
|
+
|
|
109
|
+
# Track response
|
|
110
|
+
response_started = False
|
|
111
|
+
response_status = 0
|
|
112
|
+
response_headers: dict[str, str] = {}
|
|
113
|
+
response_body_parts: list[bytes] = []
|
|
114
|
+
|
|
115
|
+
def capturing_start_response(status: str, headers: list, exc_info=None):
|
|
116
|
+
nonlocal response_started, response_status, response_headers
|
|
117
|
+
response_started = True
|
|
118
|
+
# Parse status code from "200 OK"
|
|
119
|
+
response_status = int(status.split()[0])
|
|
120
|
+
response_headers = {k: v for k, v in headers}
|
|
121
|
+
return start_response(status, headers, exc_info)
|
|
122
|
+
|
|
123
|
+
# Call the app
|
|
124
|
+
is_error = False
|
|
125
|
+
try:
|
|
126
|
+
response = self.app(environ, capturing_start_response)
|
|
127
|
+
# Collect response body
|
|
128
|
+
for chunk in response:
|
|
129
|
+
response_body_parts.append(chunk)
|
|
130
|
+
yield chunk
|
|
131
|
+
if hasattr(response, 'close'):
|
|
132
|
+
response.close()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
is_error = True
|
|
135
|
+
session.mark_error(
|
|
136
|
+
error_type=type(e).__name__,
|
|
137
|
+
error_message=str(e),
|
|
138
|
+
)
|
|
139
|
+
raise
|
|
140
|
+
finally:
|
|
141
|
+
# Calculate duration
|
|
142
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
143
|
+
|
|
144
|
+
# Capture response
|
|
145
|
+
is_error = is_error or response_status >= 400
|
|
146
|
+
response_body = b"".join(response_body_parts)
|
|
147
|
+
|
|
148
|
+
response_snapshot = self._build_response_snapshot(
|
|
149
|
+
status=response_status,
|
|
150
|
+
headers=response_headers,
|
|
151
|
+
body=response_body,
|
|
152
|
+
duration_ms=duration_ms,
|
|
153
|
+
is_error=is_error,
|
|
154
|
+
)
|
|
155
|
+
session.set_response(response_snapshot)
|
|
156
|
+
|
|
157
|
+
# Finalize and write cassette
|
|
158
|
+
session.finalize()
|
|
159
|
+
|
|
160
|
+
# Only write if errors_only is False, or if there was an error
|
|
161
|
+
if not self.config.errors_only or is_error:
|
|
162
|
+
cassette_path = write_cassette(session, self.config)
|
|
163
|
+
self._print_record_summary(session, cassette_path)
|
|
164
|
+
|
|
165
|
+
finally:
|
|
166
|
+
reset_session(token)
|
|
167
|
+
|
|
168
|
+
def _handle_replay(
|
|
169
|
+
self,
|
|
170
|
+
environ: dict[str, Any],
|
|
171
|
+
start_response: Callable,
|
|
172
|
+
) -> Any:
|
|
173
|
+
"""Handle request in replay mode."""
|
|
174
|
+
# Load cassette
|
|
175
|
+
cassette_path = self.config.cassette_path
|
|
176
|
+
if not cassette_path:
|
|
177
|
+
print("timetracer [WARN] replay mode requires TIMETRACER_CASSETTE", file=sys.stderr)
|
|
178
|
+
return self.app(environ, start_response)
|
|
179
|
+
|
|
180
|
+
cassette = read_cassette(cassette_path)
|
|
181
|
+
|
|
182
|
+
# Create replay session
|
|
183
|
+
session = ReplaySession(
|
|
184
|
+
cassette=cassette,
|
|
185
|
+
cassette_path=cassette_path,
|
|
186
|
+
strict=self.config.strict_replay,
|
|
187
|
+
config=self.config,
|
|
188
|
+
)
|
|
189
|
+
token = set_session(session)
|
|
190
|
+
|
|
191
|
+
start_time = time.perf_counter()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
# Run the app (plugins will intercept dependency calls)
|
|
195
|
+
response = self.app(environ, start_response)
|
|
196
|
+
response_body = b"".join(response)
|
|
197
|
+
if hasattr(response, 'close'):
|
|
198
|
+
response.close()
|
|
199
|
+
|
|
200
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
201
|
+
self._print_replay_summary(session, duration_ms)
|
|
202
|
+
|
|
203
|
+
yield response_body
|
|
204
|
+
|
|
205
|
+
finally:
|
|
206
|
+
reset_session(token)
|
|
207
|
+
|
|
208
|
+
def _capture_request(self, environ: dict[str, Any]) -> RequestSnapshot:
|
|
209
|
+
"""Capture incoming request data."""
|
|
210
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
211
|
+
path = environ.get("PATH_INFO", "/")
|
|
212
|
+
|
|
213
|
+
# Headers (from environ)
|
|
214
|
+
headers = {}
|
|
215
|
+
for key, value in environ.items():
|
|
216
|
+
if key.startswith("HTTP_"):
|
|
217
|
+
header_name = key[5:].replace("_", "-").lower()
|
|
218
|
+
headers[header_name] = value
|
|
219
|
+
elif key in ("CONTENT_TYPE", "CONTENT_LENGTH"):
|
|
220
|
+
header_name = key.replace("_", "-").lower()
|
|
221
|
+
headers[header_name] = value
|
|
222
|
+
|
|
223
|
+
# Redact sensitive headers
|
|
224
|
+
headers = redact_headers(headers)
|
|
225
|
+
|
|
226
|
+
# Query params
|
|
227
|
+
query_string = environ.get("QUERY_STRING", "")
|
|
228
|
+
query = self._parse_query_string(query_string)
|
|
229
|
+
|
|
230
|
+
# Client info
|
|
231
|
+
client_ip = environ.get("REMOTE_ADDR")
|
|
232
|
+
user_agent = headers.get("user-agent")
|
|
233
|
+
|
|
234
|
+
# Body
|
|
235
|
+
body_snapshot = self._capture_request_body(environ)
|
|
236
|
+
|
|
237
|
+
return RequestSnapshot(
|
|
238
|
+
method=method,
|
|
239
|
+
path=path,
|
|
240
|
+
route_template=None, # Flask doesn't expose this easily
|
|
241
|
+
headers=headers,
|
|
242
|
+
query=query,
|
|
243
|
+
body=body_snapshot,
|
|
244
|
+
client_ip=client_ip,
|
|
245
|
+
user_agent=user_agent,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _capture_request_body(self, environ: dict[str, Any]) -> BodySnapshot | None:
|
|
249
|
+
"""Capture request body data."""
|
|
250
|
+
try:
|
|
251
|
+
content_length = int(environ.get("CONTENT_LENGTH", 0))
|
|
252
|
+
except (ValueError, TypeError):
|
|
253
|
+
content_length = 0
|
|
254
|
+
|
|
255
|
+
if content_length == 0:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
# Read body
|
|
259
|
+
wsgi_input = environ.get("wsgi.input")
|
|
260
|
+
if not wsgi_input:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
body = wsgi_input.read(content_length)
|
|
264
|
+
|
|
265
|
+
# Put it back for the app to read
|
|
266
|
+
from io import BytesIO
|
|
267
|
+
environ["wsgi.input"] = BytesIO(body)
|
|
268
|
+
|
|
269
|
+
if not body:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
# Check size
|
|
273
|
+
size_bytes = len(body)
|
|
274
|
+
max_bytes = self.config.max_body_kb * 1024
|
|
275
|
+
truncated = size_bytes > max_bytes
|
|
276
|
+
|
|
277
|
+
if truncated:
|
|
278
|
+
body = body[:max_bytes]
|
|
279
|
+
|
|
280
|
+
# Try to parse as JSON
|
|
281
|
+
encoding = "bytes"
|
|
282
|
+
data: Any = None
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
data = json.loads(body.decode("utf-8"))
|
|
286
|
+
encoding = "json"
|
|
287
|
+
data = redact_body(data)
|
|
288
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
289
|
+
encoding = "bytes"
|
|
290
|
+
data = None
|
|
291
|
+
|
|
292
|
+
return BodySnapshot(
|
|
293
|
+
captured=True,
|
|
294
|
+
encoding=encoding,
|
|
295
|
+
data=data,
|
|
296
|
+
truncated=truncated,
|
|
297
|
+
size_bytes=size_bytes,
|
|
298
|
+
hash=hash_body(body),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _build_response_snapshot(
|
|
302
|
+
self,
|
|
303
|
+
status: int,
|
|
304
|
+
headers: dict[str, str],
|
|
305
|
+
body: bytes,
|
|
306
|
+
duration_ms: float,
|
|
307
|
+
is_error: bool,
|
|
308
|
+
) -> ResponseSnapshot:
|
|
309
|
+
"""Build response snapshot with policy-based capture."""
|
|
310
|
+
from timetracer.policies import should_store_body
|
|
311
|
+
|
|
312
|
+
# Redact headers
|
|
313
|
+
headers = redact_headers(headers)
|
|
314
|
+
|
|
315
|
+
# Check if we should store body
|
|
316
|
+
should_store = should_store_body(
|
|
317
|
+
self.config.store_response_body,
|
|
318
|
+
is_error=is_error,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
body_snapshot = None
|
|
322
|
+
if should_store and body:
|
|
323
|
+
size_bytes = len(body)
|
|
324
|
+
max_bytes = self.config.max_body_kb * 1024
|
|
325
|
+
truncated = size_bytes > max_bytes
|
|
326
|
+
|
|
327
|
+
if truncated:
|
|
328
|
+
body = body[:max_bytes]
|
|
329
|
+
|
|
330
|
+
# Try to parse as JSON
|
|
331
|
+
encoding = "bytes"
|
|
332
|
+
data: Any = None
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
data = json.loads(body.decode("utf-8"))
|
|
336
|
+
encoding = "json"
|
|
337
|
+
data = redact_body(data)
|
|
338
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
339
|
+
encoding = "bytes"
|
|
340
|
+
data = None
|
|
341
|
+
|
|
342
|
+
body_snapshot = BodySnapshot(
|
|
343
|
+
captured=True,
|
|
344
|
+
encoding=encoding,
|
|
345
|
+
data=data,
|
|
346
|
+
truncated=truncated,
|
|
347
|
+
size_bytes=size_bytes,
|
|
348
|
+
hash=hash_body(body),
|
|
349
|
+
)
|
|
350
|
+
elif body:
|
|
351
|
+
# Just store hash
|
|
352
|
+
body_snapshot = BodySnapshot(
|
|
353
|
+
captured=False,
|
|
354
|
+
hash=hash_body(body),
|
|
355
|
+
size_bytes=len(body),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return ResponseSnapshot(
|
|
359
|
+
status=status,
|
|
360
|
+
headers=headers,
|
|
361
|
+
body=body_snapshot,
|
|
362
|
+
duration_ms=duration_ms,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _parse_query_string(self, query_string: str) -> dict[str, str]:
|
|
366
|
+
"""Parse query string into dict."""
|
|
367
|
+
if not query_string:
|
|
368
|
+
return {}
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
from urllib.parse import parse_qs
|
|
372
|
+
parsed = parse_qs(query_string)
|
|
373
|
+
# Flatten to single values (take first)
|
|
374
|
+
return {k: v[0] if v else "" for k, v in parsed.items()}
|
|
375
|
+
except Exception:
|
|
376
|
+
return {}
|
|
377
|
+
|
|
378
|
+
def _print_record_summary(self, session: TraceSession, cassette_path: str) -> None:
|
|
379
|
+
"""Print terminal summary for record mode."""
|
|
380
|
+
req = session.request
|
|
381
|
+
res = session.response
|
|
382
|
+
|
|
383
|
+
method = req.method if req else "???"
|
|
384
|
+
path = req.path if req else "???"
|
|
385
|
+
status = res.status if res else 0
|
|
386
|
+
duration_ms = res.duration_ms if res else 0
|
|
387
|
+
|
|
388
|
+
# Event counts
|
|
389
|
+
event_counts = {}
|
|
390
|
+
for event in session.events:
|
|
391
|
+
et = event.event_type.value
|
|
392
|
+
event_counts[et] = event_counts.get(et, 0) + 1
|
|
393
|
+
|
|
394
|
+
deps_str = ", ".join(f"{k}:{v}" for k, v in event_counts.items()) or "none"
|
|
395
|
+
|
|
396
|
+
# Status icon
|
|
397
|
+
icon = "[OK]" if status < 400 else "[WARN]"
|
|
398
|
+
|
|
399
|
+
print(
|
|
400
|
+
f"timetracer {icon} recorded {method} {path} "
|
|
401
|
+
f"id={session.short_id} status={status} "
|
|
402
|
+
f"total={duration_ms:.0f}ms deps={deps_str}",
|
|
403
|
+
file=sys.stderr,
|
|
404
|
+
)
|
|
405
|
+
print(f" cassette: {cassette_path}", file=sys.stderr)
|
|
406
|
+
|
|
407
|
+
def _print_replay_summary(self, session: ReplaySession, duration_ms: float) -> None:
|
|
408
|
+
"""Print terminal summary for replay mode."""
|
|
409
|
+
req = session.request
|
|
410
|
+
method = req.method
|
|
411
|
+
path = req.path
|
|
412
|
+
|
|
413
|
+
recorded_duration = session.cassette.response.duration_ms
|
|
414
|
+
|
|
415
|
+
# Check for unconsumed events
|
|
416
|
+
unconsumed = len(session.get_unconsumed_events())
|
|
417
|
+
match_status = "OK" if unconsumed == 0 else f"WARN ({unconsumed} unconsumed)"
|
|
418
|
+
|
|
419
|
+
# Mocked counts
|
|
420
|
+
mocked_count = session.current_cursor
|
|
421
|
+
|
|
422
|
+
print(
|
|
423
|
+
f"timetracer replay {method} {path} "
|
|
424
|
+
f"mocked={mocked_count} matched={match_status} "
|
|
425
|
+
f"runtime={duration_ms:.0f}ms recorded={recorded_duration:.0f}ms",
|
|
426
|
+
file=sys.stderr,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def init_app(app: "Flask", config: TraceConfig | None = None) -> None:
|
|
431
|
+
"""
|
|
432
|
+
Initialize Timetracer for a Flask app.
|
|
433
|
+
|
|
434
|
+
Alternative to wrapping wsgi_app directly.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
app: Flask application instance.
|
|
438
|
+
config: Timetracer configuration.
|
|
439
|
+
|
|
440
|
+
Usage:
|
|
441
|
+
from flask import Flask
|
|
442
|
+
from timetracer.integrations.flask import init_app
|
|
443
|
+
from timetracer.config import TraceConfig
|
|
444
|
+
|
|
445
|
+
app = Flask(__name__)
|
|
446
|
+
init_app(app, TraceConfig(mode="record"))
|
|
447
|
+
"""
|
|
448
|
+
cfg = config or TraceConfig.from_env()
|
|
449
|
+
app.wsgi_app = TimeTraceMiddleware(app.wsgi_app, config=cfg)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def auto_setup(
|
|
453
|
+
app: "Flask",
|
|
454
|
+
config: TraceConfig | None = None,
|
|
455
|
+
plugins: list[str] | None = None,
|
|
456
|
+
) -> "Flask":
|
|
457
|
+
"""
|
|
458
|
+
One-line Timetracer setup for Flask.
|
|
459
|
+
|
|
460
|
+
Adds middleware and enables plugins automatically.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
app: Flask application instance.
|
|
464
|
+
config: Optional TraceConfig. If None, loads from environment.
|
|
465
|
+
plugins: List of plugins to enable. Default: ["requests"].
|
|
466
|
+
Options: "httpx", "requests", "sqlalchemy", "redis"
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
The app instance (for chaining).
|
|
470
|
+
|
|
471
|
+
Usage:
|
|
472
|
+
from flask import Flask
|
|
473
|
+
from timetracer.integrations.flask import auto_setup
|
|
474
|
+
|
|
475
|
+
app = auto_setup(Flask(__name__))
|
|
476
|
+
|
|
477
|
+
# Or with options:
|
|
478
|
+
app = Flask(__name__)
|
|
479
|
+
auto_setup(app, plugins=["requests", "redis"])
|
|
480
|
+
"""
|
|
481
|
+
cfg = config or TraceConfig.from_env()
|
|
482
|
+
|
|
483
|
+
# Add middleware
|
|
484
|
+
app.wsgi_app = TimeTraceMiddleware(app.wsgi_app, config=cfg)
|
|
485
|
+
|
|
486
|
+
# Enable plugins
|
|
487
|
+
enabled_plugins = plugins or ["requests"]
|
|
488
|
+
|
|
489
|
+
for plugin in enabled_plugins:
|
|
490
|
+
if plugin == "httpx":
|
|
491
|
+
from timetracer.plugins import enable_httpx
|
|
492
|
+
enable_httpx()
|
|
493
|
+
elif plugin == "requests":
|
|
494
|
+
from timetracer.plugins import enable_requests
|
|
495
|
+
enable_requests()
|
|
496
|
+
elif plugin == "sqlalchemy":
|
|
497
|
+
from timetracer.plugins import enable_sqlalchemy
|
|
498
|
+
enable_sqlalchemy()
|
|
499
|
+
elif plugin == "redis":
|
|
500
|
+
from timetracer.plugins import enable_redis
|
|
501
|
+
enable_redis()
|
|
502
|
+
|
|
503
|
+
return app
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# Backwards compatibility alias
|
|
507
|
+
timetracerMiddleware = TimeTraceMiddleware
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugins module for Timetracer.
|
|
3
|
+
|
|
4
|
+
Plugins capture and replay dependency calls (HTTP, DB, Redis, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from timetracer.plugins.httpx_plugin import disable_httpx, enable_httpx
|
|
8
|
+
from timetracer.plugins.requests_plugin import disable_requests, enable_requests
|
|
9
|
+
|
|
10
|
+
# SQLAlchemy is optional - only import if available
|
|
11
|
+
try:
|
|
12
|
+
from timetracer.plugins.sqlalchemy_plugin import disable_sqlalchemy, enable_sqlalchemy
|
|
13
|
+
_HAS_SQLALCHEMY = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
_HAS_SQLALCHEMY = False
|
|
16
|
+
|
|
17
|
+
def enable_sqlalchemy(*args, **kwargs):
|
|
18
|
+
raise ImportError("sqlalchemy is required. Install with: pip install timetracer[sqlalchemy]")
|
|
19
|
+
|
|
20
|
+
def disable_sqlalchemy(*args, **kwargs):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
# Redis is optional - only import if available
|
|
24
|
+
try:
|
|
25
|
+
from timetracer.plugins.redis_plugin import disable_redis, enable_redis
|
|
26
|
+
_HAS_REDIS = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
_HAS_REDIS = False
|
|
29
|
+
|
|
30
|
+
def enable_redis(*args, **kwargs):
|
|
31
|
+
raise ImportError("redis is required. Install with: pip install timetracer[redis]")
|
|
32
|
+
|
|
33
|
+
def disable_redis(*args, **kwargs):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"enable_httpx", "disable_httpx",
|
|
38
|
+
"enable_requests", "disable_requests",
|
|
39
|
+
"enable_sqlalchemy", "disable_sqlalchemy",
|
|
40
|
+
"enable_redis", "disable_redis",
|
|
41
|
+
]
|
|
42
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base plugin infrastructure.
|
|
3
|
+
|
|
4
|
+
Defines the plugin protocol and registry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from timetracer.constants import EventType
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from timetracer.config import TraceConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class TracePlugin(Protocol):
|
|
19
|
+
"""
|
|
20
|
+
Protocol for Timetracer plugins.
|
|
21
|
+
|
|
22
|
+
Plugins must implement this interface to integrate with the system.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
"""Unique plugin identifier."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def event_type(self) -> EventType:
|
|
32
|
+
"""The type of events this plugin captures."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def setup(self, config: TraceConfig) -> None:
|
|
36
|
+
"""Initialize the plugin with configuration."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def enable_recording(self) -> None:
|
|
40
|
+
"""Start capturing events."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def enable_replay(self) -> None:
|
|
44
|
+
"""Start mocking calls with recorded data."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def disable(self) -> None:
|
|
48
|
+
"""Stop capturing/mocking and restore original behavior."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Plugin registry
|
|
53
|
+
_registered_plugins: dict[str, TracePlugin] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_plugin(plugin: TracePlugin) -> None:
|
|
57
|
+
"""Register a plugin globally."""
|
|
58
|
+
_registered_plugins[plugin.name] = plugin
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_plugin(name: str) -> TracePlugin | None:
|
|
62
|
+
"""Get a registered plugin by name."""
|
|
63
|
+
return _registered_plugins.get(name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_all_plugins() -> dict[str, TracePlugin]:
|
|
67
|
+
"""Get all registered plugins."""
|
|
68
|
+
return _registered_plugins.copy()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_plugins() -> None:
|
|
72
|
+
"""Clear all registered plugins (for testing)."""
|
|
73
|
+
_registered_plugins.clear()
|