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.
- flask_redline/__init__.py +43 -0
- flask_redline/_hooks.py +137 -0
- flask_redline/_httpx.py +5 -0
- flask_redline/_middleware.py +392 -0
- flask_redline/_requests.py +22 -0
- flask_redline/_security.py +162 -0
- flask_redline/_sqlalchemy.py +19 -0
- flask_redline/blueprint.py +186 -0
- flask_redline/cli.py +141 -0
- flask_redline/integration.py +292 -0
- flask_redline/py.typed +0 -0
- flask_redline/suggest.py +5 -0
- flask_redline-0.2.0a1.dist-info/METADATA +289 -0
- flask_redline-0.2.0a1.dist-info/RECORD +16 -0
- flask_redline-0.2.0a1.dist-info/WHEEL +4 -0
- flask_redline-0.2.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
flask_redline/_hooks.py
ADDED
|
@@ -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]
|
flask_redline/_httpx.py
ADDED
|
@@ -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()
|