flask-redline 0.2.0a1__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.
@@ -0,0 +1,43 @@
1
+ """flask-redline: the Flask adapter for Redline.
2
+
3
+ ::
4
+
5
+ from flask import Flask
6
+ from sqlalchemy import create_engine
7
+ from flask_redline import Redline
8
+
9
+ app = Flask(__name__)
10
+ Redline(app, engine=create_engine("sqlite:///app.db")) # UI at /_redline/
11
+
12
+ # application factory
13
+ redline = Redline(engine=engine)
14
+ redline.init_app(app)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ._httpx import instrument_httpx, uninstrument_httpx
20
+ from ._middleware import RedlineMiddleware
21
+ from ._requests import instrument_requests, uninstrument_requests
22
+ from ._security import RedlineHTTPError, check_csrf, local_only, loopback_guard
23
+ from ._sqlalchemy import instrument_engine
24
+ from .integration import Redline
25
+ from .suggest import SQLAlchemyFixSuggester
26
+
27
+ __version__ = "0.2.0a1"
28
+
29
+ __all__ = [
30
+ "Redline",
31
+ "RedlineHTTPError",
32
+ "RedlineMiddleware",
33
+ "SQLAlchemyFixSuggester",
34
+ "__version__",
35
+ "check_csrf",
36
+ "instrument_engine",
37
+ "instrument_httpx",
38
+ "instrument_requests",
39
+ "local_only",
40
+ "loopback_guard",
41
+ "uninstrument_httpx",
42
+ "uninstrument_requests",
43
+ ]
@@ -0,0 +1,137 @@
1
+ """Flask hooks: route template, view span, template spans and unhandled errors.
2
+
3
+ Installed once per app from ``Redline.init_app`` (not at all when ``enabled=False``). Each
4
+ hook is a cheap no-op when no trace is active – one ``ContextVar.get()`` – and they write
5
+ into the trace the WSGI middleware started, found through the contextvar:
6
+
7
+ * ``request_started`` – the route template (``/orders/<int:order_id>``) and the view's
8
+ dotted name, known as soon as Flask has matched the URL;
9
+ * ``app.dispatch_request`` – wrapped in a ``view`` span (only the view call, not the
10
+ ``before_request`` / ``after_request`` hooks), also for ``async def`` views;
11
+ * ``before_render_template`` / ``template_rendered`` – a ``template`` span per top-level
12
+ render; queries a template triggers (lazy loading) nest under it;
13
+ * ``app.async_to_sync`` – every ``async def`` view/hook runs inside
14
+ :func:`redline_core.blocking.track_request` on the event loop asgiref creates for it, so
15
+ a blocking call in a coroutine (``time.sleep``, a sync driver) becomes a ``blocking``
16
+ span;
17
+ * ``got_request_exception`` – the unhandled exception for the trace's ``error``. Flask
18
+ turns ``HTTPException`` (``abort(404)``) into a response without this signal, so a 4xx is
19
+ never an error.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextvars
25
+ import functools
26
+ from typing import Any
27
+
28
+ from flask import Flask, got_request_exception, request, request_started
29
+ from flask.signals import before_render_template, template_rendered
30
+
31
+ from redline_core.blocking import track_request
32
+ from redline_core.recorder import SpanContext, current
33
+
34
+ from ._middleware import ERROR_KEY, PASSTHROUGH_KEY
35
+
36
+ __all__ = ["install_hooks", "view_name"]
37
+
38
+ _open_templates: contextvars.ContextVar[list[SpanContext] | None] = contextvars.ContextVar(
39
+ "redline_flask_templates", default=None
40
+ )
41
+
42
+
43
+ def view_name(app: Flask, endpoint: str) -> str | None:
44
+ """Dotted name of the view function behind ``endpoint`` (``shop.views.order_detail``)."""
45
+ func = app.view_functions.get(endpoint)
46
+ if func is None:
47
+ return None
48
+ target = getattr(func, "view_class", None) or getattr(func, "func", None) or func
49
+ module = getattr(target, "__module__", None)
50
+ name = getattr(target, "__qualname__", None) or getattr(target, "__name__", None)
51
+ if name is None:
52
+ name, module = type(target).__qualname__, type(target).__module__
53
+ return f"{module}.{name}" if module else name
54
+
55
+
56
+ def install_hooks(app: Flask, on_first_request: Any = None) -> None:
57
+ """Connect the signal receivers and wrap ``dispatch_request`` (idempotent)."""
58
+ if getattr(app, "_redline_hooks", False):
59
+ return
60
+ app._redline_hooks = True # type: ignore[attr-defined]
61
+
62
+ # blinker holds receivers weakly by default; these closures must outlive this call.
63
+ def on_request_started(sender: Flask, **extra: Any) -> None:
64
+ _open_templates.set(None) # nothing can be mid-render at the start of a request
65
+ if on_first_request is not None:
66
+ on_first_request()
67
+ builder = current()
68
+ rule = request.url_rule
69
+ if builder is None or rule is None:
70
+ return
71
+ builder.set_route(
72
+ request.script_root + rule.rule,
73
+ view_name(sender, rule.endpoint),
74
+ request.view_args or None,
75
+ )
76
+
77
+ def on_exception(sender: Flask, exception: BaseException, **extra: Any) -> None:
78
+ request.environ[ERROR_KEY] = exception
79
+ for ctx in reversed(_open_templates.get() or []): # a render that raised
80
+ ctx.__exit__(type(exception), exception, None)
81
+ _open_templates.set(None)
82
+
83
+ def before_render(sender: Flask, template: Any, **extra: Any) -> None:
84
+ if current() is None:
85
+ return
86
+ name = template.name or "<string>"
87
+ ctx = SpanContext(name, "template", {"template": name})
88
+ ctx.__enter__()
89
+ stack = _open_templates.get()
90
+ if stack is None:
91
+ stack = []
92
+ _open_templates.set(stack)
93
+ stack.append(ctx)
94
+
95
+ def after_render(sender: Flask, template: Any, **extra: Any) -> None:
96
+ stack = _open_templates.get()
97
+ if stack:
98
+ stack.pop().__exit__(None, None, None)
99
+
100
+ def after_request(response: Any) -> Any:
101
+ # A file served by the WSGI server must come back from the middleware untouched.
102
+ if response.direct_passthrough:
103
+ request.environ[PASSTHROUGH_KEY] = True
104
+ return response
105
+
106
+ request_started.connect(on_request_started, app, weak=False)
107
+ got_request_exception.connect(on_exception, app, weak=False)
108
+ before_render_template.connect(before_render, app, weak=False)
109
+ template_rendered.connect(after_render, app, weak=False)
110
+ app.after_request(after_request)
111
+
112
+ original = app.dispatch_request
113
+
114
+ def dispatch_request() -> Any:
115
+ rule = request.url_rule
116
+ if rule is None or current() is None:
117
+ return original()
118
+ name = view_name(app, rule.endpoint) or rule.endpoint
119
+ with SpanContext(name, "view", {"endpoint": rule.endpoint}):
120
+ return original()
121
+
122
+ app.dispatch_request = dispatch_request # type: ignore[method-assign]
123
+
124
+ to_sync = app.async_to_sync
125
+
126
+ def async_to_sync(func: Any) -> Any:
127
+ @functools.wraps(func)
128
+ async def tracked(*args: Any, **kwargs: Any) -> Any:
129
+ builder = current() # asgiref copies the context into its loop thread
130
+ if builder is None:
131
+ return await func(*args, **kwargs)
132
+ async with track_request(builder): # binds the loop this coroutine runs on
133
+ return await func(*args, **kwargs)
134
+
135
+ return to_sync(tracked)
136
+
137
+ app.async_to_sync = async_to_sync # type: ignore[method-assign]
@@ -0,0 +1,5 @@
1
+ """Compatibility shim: the implementation lives in :mod:`redline_core.contrib.httpx`."""
2
+
3
+ from redline_core.contrib.httpx import instrument_httpx, uninstrument_httpx
4
+
5
+ __all__ = ["instrument_httpx", "uninstrument_httpx"]
@@ -0,0 +1,392 @@
1
+ """The WSGI recording middleware, wrapped around ``app.wsgi_app``.
2
+
3
+ It owns the *request-level* facts that Flask's hooks cannot see: total time, the final
4
+ status and headers, and the response body. The route template, view, template spans and
5
+ errors come from Flask's own hooks (``_hooks.py``); they reach the trace through the
6
+ contextvar, so both halves write into the same trace.
7
+
8
+ * the request body is *teed* as the app reads it (the app sees the same bytes, nothing is
9
+ read up front);
10
+ * the response is wrapped in an iterator that keeps a size-capped copy of the body – a
11
+ stream (``stream_with_context``, a generator) is never buffered beyond
12
+ ``max_body_bytes`` and is not consumed early;
13
+ * the trace finishes when the last chunk has been handed to the server (or the server
14
+ closes the response), so a generator's own work counts towards the latency;
15
+ * ``direct_passthrough`` responses (``send_file``, anything served by the server's
16
+ ``wsgi.file_wrapper``) are returned untouched, so ``sendfile`` still works; only the size
17
+ is recorded;
18
+ * recorded responses get a ``Server-Timing`` header (``Settings.server_timing``, appended
19
+ to one the app set) and, when the badge is on, small non-streamed ``text/html`` 2xx
20
+ pages get the badge ``<script>`` before ``</body>`` (Content-Length fixed). Streams,
21
+ files, compressed bodies, pages over ``ui.BADGE_MAX_BYTES`` and pages whose CSP does
22
+ not allow ``'self'`` scripts are left alone.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import re
29
+ from collections.abc import Callable, Iterable, Iterator
30
+ from typing import Any
31
+
32
+ from werkzeug.datastructures import EnvironHeaders
33
+ from werkzeug.wsgi import FileWrapper, get_path_info
34
+
35
+ from redline_core import Recorder, ui
36
+ from redline_core.recorder import TraceBuilder
37
+ from redline_core.sampling import profile_requested
38
+
39
+ __all__ = ["ERROR_KEY", "PASSTHROUGH_KEY", "RedlineMiddleware"]
40
+
41
+ logger = logging.getLogger("redline")
42
+
43
+ #: ``environ`` keys the Flask hooks use to talk to the middleware.
44
+ ERROR_KEY = "redline.error"
45
+ PASSTHROUGH_KEY = "redline.passthrough"
46
+
47
+ _JSON_PAIR = re.compile(r'("(?:[^"\\]|\\.)*")(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\]\s]+)')
48
+
49
+
50
+ class _Capture:
51
+ """A byte buffer that keeps at most ``limit + 1`` bytes (the +1 lets core flag truncation)."""
52
+
53
+ __slots__ = ("buf", "limit", "total")
54
+
55
+ def __init__(self, limit: int) -> None:
56
+ self.buf = bytearray()
57
+ self.limit = limit
58
+ self.total = 0
59
+
60
+ def add(self, chunk: bytes) -> None:
61
+ self.total += len(chunk)
62
+ room = self.limit + 1 - len(self.buf)
63
+ if room > 0 and chunk:
64
+ self.buf += chunk[:room]
65
+
66
+ def value(self, recorder: Recorder, content_type: str | None) -> bytes:
67
+ data = bytes(self.buf)
68
+ if len(data) > self.limit and content_type and "json" in content_type:
69
+ # A cut JSON document cannot be parsed by core's key-based masking; mask the
70
+ # visible "key": value pairs so a secret in the first bytes never leaks.
71
+ redactor = recorder.redactor
72
+ text = data.decode("utf-8", errors="ignore")
73
+ text = _JSON_PAIR.sub(
74
+ lambda m: (
75
+ f'{m.group(1)}{m.group(2)}"[redacted]"'
76
+ if redactor.is_secret_key(m.group(1)[1:-1])
77
+ else m.group(0)
78
+ ),
79
+ text,
80
+ )
81
+ data = text.encode("utf-8")
82
+ return data
83
+
84
+
85
+ class _TeeInput:
86
+ """``wsgi.input`` that copies what the app reads into a :class:`_Capture`."""
87
+
88
+ def __init__(self, stream: Any, capture: _Capture) -> None:
89
+ self._stream = stream
90
+ self._capture = capture
91
+
92
+ def read(self, size: int = -1) -> bytes:
93
+ data = self._stream.read(size)
94
+ self._capture.add(data)
95
+ return data
96
+
97
+ def readline(self, size: int = -1) -> bytes:
98
+ data = self._stream.readline(size)
99
+ self._capture.add(data)
100
+ return data
101
+
102
+ def readlines(self, hint: int = -1) -> list[bytes]:
103
+ lines = self._stream.readlines(hint)
104
+ for line in lines:
105
+ self._capture.add(line)
106
+ return lines
107
+
108
+ def readinto(self, buffer: Any) -> int | None:
109
+ # werkzeug's LimitedStream prefers ``readinto`` when the stream has one.
110
+ if hasattr(self._stream, "readinto"):
111
+ n = self._stream.readinto(buffer)
112
+ else:
113
+ data = self._stream.read(len(buffer))
114
+ n = len(data)
115
+ buffer[:n] = data
116
+ if n:
117
+ self._capture.add(bytes(buffer[:n]))
118
+ return n
119
+
120
+ def __iter__(self) -> Iterator[bytes]:
121
+ yield from iter(self.readline, b"")
122
+
123
+ def __getattr__(self, name: str) -> Any:
124
+ return getattr(self._stream, name)
125
+
126
+
127
+ class _Body:
128
+ """The response iterable: passes chunks through, copies a capped prefix, then finishes."""
129
+
130
+ def __init__(
131
+ self, inner: Iterable[bytes], capture: _Capture | None, finish: Callable[..., None]
132
+ ) -> None:
133
+ self._inner = inner
134
+ self._iter = iter(inner)
135
+ self._capture = capture
136
+ self._finish = finish
137
+ self._total = 0
138
+
139
+ def __iter__(self) -> _Body:
140
+ return self
141
+
142
+ def __next__(self) -> bytes:
143
+ try:
144
+ chunk = next(self._iter)
145
+ except StopIteration:
146
+ self._finish(size=self._total)
147
+ raise
148
+ except Exception as exc: # the generator failed after the headers went out
149
+ self._finish(size=self._total, error=exc)
150
+ raise
151
+ self._total += len(chunk)
152
+ if self._capture is not None:
153
+ self._capture.add(chunk)
154
+ return chunk
155
+
156
+ def close(self) -> None:
157
+ try:
158
+ close = getattr(self._inner, "close", None)
159
+ if close is not None:
160
+ close()
161
+ finally:
162
+ self._finish(size=self._total)
163
+
164
+
165
+ def _header(headers: Iterable[tuple[str, str]], name: str) -> str | None:
166
+ for key, value in headers:
167
+ if key.lower() == name:
168
+ return value
169
+ return None
170
+
171
+
172
+ class RedlineMiddleware:
173
+ """Record one trace per request. Wraps ``app.wsgi_app`` (see ``Redline.init_app``).
174
+
175
+ ``badge`` is a ``() -> bool`` telling whether the floating badge is on right now
176
+ (``Settings.badge``, ``"auto"`` = ``app.debug``); ``None`` never injects it.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ wsgi_app: Any,
182
+ recorder: Recorder,
183
+ prefix: str = "/_redline",
184
+ badge: Callable[[], bool] | None = None,
185
+ debug: Callable[[], bool] | None = None,
186
+ force_ok: Callable[[dict[str, Any]], bool] | None = None,
187
+ ) -> None:
188
+ self.wsgi_app = wsgi_app
189
+ self.recorder = recorder
190
+ self.prefix = prefix
191
+ self.badge = badge
192
+ #: ``app.debug`` right now (``server_timing="auto"``).
193
+ self.debug = debug or (lambda: False)
194
+ #: ``force_ok(environ)``: may this request force profiling? Only called when
195
+ #: ``X-Redline-Profile`` / ``?_redline=profile`` is present.
196
+ self.force_ok = force_ok or (lambda environ: self.debug())
197
+
198
+ def __call__(self, environ: dict[str, Any], start_response: Any) -> Any:
199
+ recorder = self.recorder
200
+ script = environ.get("SCRIPT_NAME", "").encode("latin-1").decode("utf-8", "replace")
201
+ path_info = get_path_info(environ)
202
+ path = script + path_info
203
+ prefix = self.prefix
204
+ if (
205
+ path_info == prefix
206
+ or path_info.startswith(prefix + "/")
207
+ or not recorder.should_record(path)
208
+ ):
209
+ return self.wsgi_app(environ, start_response)
210
+
211
+ settings = recorder.settings
212
+ headers = dict(EnvironHeaders(environ).items())
213
+ query = environ.get("QUERY_STRING", "")
214
+ method = environ.get("REQUEST_METHOD", "GET")
215
+ builder = recorder.start_trace(
216
+ framework="flask", # type: ignore[arg-type]
217
+ method=method,
218
+ path=path,
219
+ headers=headers,
220
+ query=query,
221
+ client=environ.get("REMOTE_ADDR") or None,
222
+ profile_requested=(
223
+ profile_requested(query, headers.get("X-Redline-Profile"))
224
+ and self.force_ok(environ)
225
+ ),
226
+ )
227
+ limit = settings.max_body_bytes
228
+ req_type = headers.get("Content-Type")
229
+ req: _Capture | None = None
230
+ if (
231
+ settings.capture_request_body
232
+ and environ.get("wsgi.input") is not None
233
+ and not (req_type or "").startswith("multipart/")
234
+ ):
235
+ req = _Capture(limit)
236
+ environ["wsgi.input"] = _TeeInput(environ["wsgi.input"], req)
237
+ res = _Capture(limit) if settings.capture_response_body else None
238
+ state: dict[str, Any] = {
239
+ "status": None,
240
+ "headers": [],
241
+ "done": False,
242
+ "written": 0,
243
+ "deferred": None, # (status line, headers) held back to inject the badge
244
+ }
245
+ badge = self.badge is not None and method != "HEAD" and builder.active and self.badge()
246
+
247
+ def finish(
248
+ status: int | None = None, error: BaseException | None = None, size: int | None = None
249
+ ) -> None:
250
+ if state["done"]:
251
+ return
252
+ state["done"] = True
253
+ try:
254
+ if req is not None and req.total:
255
+ builder.req_body = req.value(recorder, req_type)
256
+ resp_headers = state["headers"]
257
+ final = status or state["status"] or 500
258
+ error = error or environ.get(ERROR_KEY)
259
+ if error is not None and 400 <= final < 500:
260
+ error = None # abort(404) & co.: a client error, not a failure
261
+ body = None
262
+ if res is not None and _header(resp_headers, "content-encoding") is None:
263
+ body = res.value(recorder, _header(resp_headers, "content-type"))
264
+ if size is None:
265
+ length = _header(resp_headers, "content-length")
266
+ size = int(length) if length and length.isdigit() else None
267
+ recorder.finish_trace(
268
+ builder,
269
+ status=final,
270
+ headers=resp_headers,
271
+ body=body,
272
+ size=size,
273
+ error=error,
274
+ )
275
+ except Exception: # pragma: no cover - recording must never break a request
276
+ logger.exception("Redline failed to finish a trace")
277
+
278
+ def start(status: str, response_headers: list[tuple[str, str]], exc_info: Any = None):
279
+ code = int(status.split(None, 1)[0])
280
+ state["status"] = code
281
+ response_headers = self._finalise_headers(builder, response_headers)
282
+ state["headers"] = response_headers
283
+ if badge and exc_info is None and _badge_candidate(code, response_headers, environ):
284
+ state["deferred"] = (status, response_headers)
285
+ write = None
286
+ else:
287
+ write = start_response(status, response_headers, exc_info)
288
+
289
+ def write_wrapper(data: bytes) -> Any:
290
+ nonlocal write
291
+ if write is None: # the legacy write() API: no injection after all
292
+ held, state["deferred"] = state["deferred"], None
293
+ write = start_response(*held)
294
+ state["written"] += len(data)
295
+ if res is not None:
296
+ res.add(data)
297
+ return write(data)
298
+
299
+ return write_wrapper
300
+
301
+ try:
302
+ iterable = self.wsgi_app(environ, start)
303
+ if state["deferred"] is not None:
304
+ iterable = self._inject_badge(iterable, builder, environ, state, start_response)
305
+ except Exception as exc:
306
+ finish(status=state["status"] or 500, error=exc)
307
+ raise
308
+ except BaseException: # the client went away / the worker is shutting down
309
+ finish(status=state["status"] or 499)
310
+ raise
311
+
312
+ if environ.get(PASSTHROUGH_KEY) or isinstance(iterable, _file_wrappers(environ)):
313
+ # send_file & co.: hand the server its own file wrapper back, untouched.
314
+ finish(size=None)
315
+ return iterable
316
+ return self._finalise_body(_Body(iterable, res, finish), builder, environ)
317
+
318
+ # The one place where response headers and body are finalised: Server-Timing and the
319
+ # floating badge.
320
+ def _finalise_headers(
321
+ self, builder: TraceBuilder, headers: list[tuple[str, str]]
322
+ ) -> list[tuple[str, str]]:
323
+ if not ui.server_timing_enabled(self.recorder.settings, self.debug()):
324
+ return headers
325
+ timing = builder.server_timing()
326
+ if not timing:
327
+ return headers
328
+ out = []
329
+ merged = False
330
+ for key, value in headers:
331
+ if not merged and key.lower() == "server-timing":
332
+ value = f"{value}, {timing}" if value.strip() else timing
333
+ merged = True
334
+ out.append((key, value))
335
+ if not merged:
336
+ out.append(("Server-Timing", timing))
337
+ return out
338
+
339
+ def _finalise_body(self, body: _Body, builder: TraceBuilder, environ: dict[str, Any]) -> Any:
340
+ return body
341
+
342
+ def _inject_badge(
343
+ self,
344
+ iterable: Iterable[bytes],
345
+ builder: TraceBuilder,
346
+ environ: dict[str, Any],
347
+ state: dict[str, Any],
348
+ start_response: Any,
349
+ ) -> Iterable[bytes]:
350
+ """Read the (small, non-streamed) HTML body, add the badge, fix Content-Length and
351
+ only then start the response."""
352
+ status_line, headers = state["deferred"]
353
+ state["deferred"] = None
354
+ try:
355
+ body = b"".join(iterable)
356
+ finally:
357
+ close = getattr(iterable, "close", None)
358
+ if close is not None:
359
+ close()
360
+ script = environ.get("SCRIPT_NAME", "").rstrip("/")
361
+ snippet = ui.badge_snippet(
362
+ builder.id, script + self.prefix, ui.badge_summary(builder, state["status"])
363
+ )
364
+ injected = ui.inject_badge(body, snippet)
365
+ if injected is not None:
366
+ body = injected
367
+ headers = [(k, v) for k, v in headers if k.lower() != "content-length"]
368
+ headers.append(("Content-Length", str(len(body))))
369
+ state["headers"] = headers
370
+ start_response(status_line, headers)
371
+ return [body]
372
+
373
+
374
+ def _badge_candidate(status: int, headers: list[tuple[str, str]], environ: dict[str, Any]) -> bool:
375
+ """HTML, 2xx, a known small length (not streamed), not a file or download, CSP allows it."""
376
+ if environ.get(PASSTHROUGH_KEY):
377
+ return False
378
+ length = _header(headers, "content-length")
379
+ if not length or not length.isdigit() or int(length) > ui.BADGE_MAX_BYTES:
380
+ return False
381
+ return ui.badge_allowed(
382
+ status,
383
+ _header(headers, "content-type"),
384
+ content_encoding=_header(headers, "content-encoding"),
385
+ csp=", ".join(v for k, v in headers if k.lower() == "content-security-policy"),
386
+ content_disposition=_header(headers, "content-disposition"),
387
+ )
388
+
389
+
390
+ def _file_wrappers(environ: dict[str, Any]) -> tuple[type, ...]:
391
+ server = environ.get("wsgi.file_wrapper")
392
+ return (FileWrapper, server) if isinstance(server, type) else (FileWrapper,)
@@ -0,0 +1,22 @@
1
+ """Opt-in outbound ``requests`` spans; the implementation is :mod:`redline_core.contrib.requests`.
2
+
3
+ ``requests`` is not a dependency of flask-redline, so it is imported on first use.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __all__ = ["instrument_requests", "uninstrument_requests"]
9
+
10
+
11
+ def instrument_requests() -> bool:
12
+ """Patch ``requests`` so every call becomes an ``http_out`` span. Idempotent."""
13
+ from redline_core.contrib.requests import instrument_requests as instrument
14
+
15
+ return instrument()
16
+
17
+
18
+ def uninstrument_requests() -> None:
19
+ """Undo :func:`instrument_requests`."""
20
+ from redline_core.contrib.requests import uninstrument_requests as uninstrument
21
+
22
+ uninstrument()