agentrail-python 0.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.
- agentrail_python/__init__.py +64 -0
- agentrail_python/_client.py +362 -0
- agentrail_python/_handoff.py +214 -0
- agentrail_python/_init.py +64 -0
- agentrail_python/_options.py +305 -0
- agentrail_python/_outbound.py +76 -0
- agentrail_python/_performance.py +63 -0
- agentrail_python/_privacy.py +255 -0
- agentrail_python/_processors.py +259 -0
- agentrail_python/_queue.py +236 -0
- agentrail_python/_runtime.py +110 -0
- agentrail_python/_suppression.py +33 -0
- agentrail_python/_text.py +49 -0
- agentrail_python/_version.py +2 -0
- agentrail_python/asgi.py +111 -0
- agentrail_python/httpx.py +159 -0
- agentrail_python/logging.py +70 -0
- agentrail_python/mcp.py +125 -0
- agentrail_python/py.typed +0 -0
- agentrail_python/requests.py +67 -0
- agentrail_python/wsgi.py +158 -0
- agentrail_python-0.1.0.dist-info/METADATA +625 -0
- agentrail_python-0.1.0.dist-info/RECORD +25 -0
- agentrail_python-0.1.0.dist-info/WHEEL +4 -0
- agentrail_python-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""httpx transports that sign trace context only for allowed origins.
|
|
3
|
+
|
|
4
|
+
``httpx.Client(transport=agentrail_python.httpx.transport(agentrail))``. Each
|
|
5
|
+
redirect passes through the transport again, so every actual destination is
|
|
6
|
+
admitted on its own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator, Iterator
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._handoff import OWNED_HEADERS
|
|
17
|
+
from ._outbound import begin, finish, origin
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from opentelemetry.trace import Span
|
|
21
|
+
|
|
22
|
+
from ._client import Client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _prepare(client: Client, request: httpx.Request) -> Span | None:
|
|
26
|
+
url = request.url
|
|
27
|
+
target = url.raw_path.decode("ascii")
|
|
28
|
+
outgoing = begin(
|
|
29
|
+
client,
|
|
30
|
+
request.method,
|
|
31
|
+
origin(url.scheme, url.host, url.port),
|
|
32
|
+
target.split("?", 1)[0],
|
|
33
|
+
target,
|
|
34
|
+
str(url),
|
|
35
|
+
)
|
|
36
|
+
if outgoing.strip:
|
|
37
|
+
for name in OWNED_HEADERS:
|
|
38
|
+
request.headers.pop(name, None)
|
|
39
|
+
for name, value in outgoing.headers.items():
|
|
40
|
+
request.headers[name] = value
|
|
41
|
+
return outgoing.span
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Stream(httpx.SyncByteStream):
|
|
45
|
+
def __init__(self, stream: httpx.SyncByteStream, span: Span, status: int) -> None:
|
|
46
|
+
self._stream = stream
|
|
47
|
+
self._span = span
|
|
48
|
+
self._status = status
|
|
49
|
+
self._done = False
|
|
50
|
+
|
|
51
|
+
def _finish(self, error: BaseException | None) -> None:
|
|
52
|
+
if not self._done:
|
|
53
|
+
self._done = True
|
|
54
|
+
finish(self._span, self._status, error)
|
|
55
|
+
|
|
56
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
57
|
+
try:
|
|
58
|
+
yield from self._stream
|
|
59
|
+
except BaseException as error:
|
|
60
|
+
self._finish(error)
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
try:
|
|
65
|
+
self._stream.close()
|
|
66
|
+
except BaseException as error:
|
|
67
|
+
self._finish(error)
|
|
68
|
+
raise
|
|
69
|
+
self._finish(None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _AsyncStream(httpx.AsyncByteStream):
|
|
73
|
+
def __init__(self, stream: httpx.AsyncByteStream, span: Span, status: int) -> None:
|
|
74
|
+
self._stream = stream
|
|
75
|
+
self._span = span
|
|
76
|
+
self._status = status
|
|
77
|
+
self._done = False
|
|
78
|
+
|
|
79
|
+
def _finish(self, error: BaseException | None) -> None:
|
|
80
|
+
if not self._done:
|
|
81
|
+
self._done = True
|
|
82
|
+
finish(self._span, self._status, error)
|
|
83
|
+
|
|
84
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
85
|
+
try:
|
|
86
|
+
async for chunk in self._stream:
|
|
87
|
+
yield chunk
|
|
88
|
+
except BaseException as error:
|
|
89
|
+
self._finish(error)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
async def aclose(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
await self._stream.aclose()
|
|
95
|
+
except BaseException as error:
|
|
96
|
+
self._finish(error)
|
|
97
|
+
raise
|
|
98
|
+
self._finish(None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AgentrailTransport(httpx.BaseTransport):
|
|
102
|
+
def __init__(self, client: Client, base: httpx.BaseTransport | None = None) -> None:
|
|
103
|
+
self._client = client
|
|
104
|
+
self._base = base if base is not None else httpx.HTTPTransport()
|
|
105
|
+
|
|
106
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
107
|
+
span = _prepare(self._client, request)
|
|
108
|
+
if span is None:
|
|
109
|
+
return self._base.handle_request(request)
|
|
110
|
+
try:
|
|
111
|
+
response = self._base.handle_request(request)
|
|
112
|
+
except BaseException as error:
|
|
113
|
+
finish(span, None, error)
|
|
114
|
+
raise
|
|
115
|
+
stream = response.stream
|
|
116
|
+
if response.is_closed or not isinstance(stream, httpx.SyncByteStream):
|
|
117
|
+
# A body already read (or none to read) ends the call now.
|
|
118
|
+
finish(span, response.status_code, None)
|
|
119
|
+
return response
|
|
120
|
+
response.stream = _Stream(stream, span, response.status_code)
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
self._base.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AgentrailAsyncTransport(httpx.AsyncBaseTransport):
|
|
128
|
+
def __init__(self, client: Client, base: httpx.AsyncBaseTransport | None = None) -> None:
|
|
129
|
+
self._client = client
|
|
130
|
+
self._base = base if base is not None else httpx.AsyncHTTPTransport()
|
|
131
|
+
|
|
132
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
133
|
+
span = _prepare(self._client, request)
|
|
134
|
+
if span is None:
|
|
135
|
+
return await self._base.handle_async_request(request)
|
|
136
|
+
try:
|
|
137
|
+
response = await self._base.handle_async_request(request)
|
|
138
|
+
except BaseException as error:
|
|
139
|
+
finish(span, None, error)
|
|
140
|
+
raise
|
|
141
|
+
stream = response.stream
|
|
142
|
+
if response.is_closed or not isinstance(stream, httpx.AsyncByteStream):
|
|
143
|
+
finish(span, response.status_code, None)
|
|
144
|
+
return response
|
|
145
|
+
response.stream = _AsyncStream(stream, span, response.status_code)
|
|
146
|
+
return response
|
|
147
|
+
|
|
148
|
+
async def aclose(self) -> None:
|
|
149
|
+
await self._base.aclose()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def transport(client: Client, base: httpx.BaseTransport | None = None) -> AgentrailTransport:
|
|
153
|
+
"""A transport for ``httpx.Client``. ``base`` defaults to ``httpx.HTTPTransport()``."""
|
|
154
|
+
return AgentrailTransport(client, base)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def async_transport(client: Client, base: httpx.AsyncBaseTransport | None = None) -> AgentrailAsyncTransport:
|
|
158
|
+
"""A transport for ``httpx.AsyncClient``. ``base`` defaults to ``httpx.AsyncHTTPTransport()``."""
|
|
159
|
+
return AgentrailAsyncTransport(client, base)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""A ``logging.Handler`` that records structured logs, correlated with the request.
|
|
3
|
+
|
|
4
|
+
``logging.getLogger().addHandler(AgentrailHandler(agentrail))``. The record is
|
|
5
|
+
captured where it is emitted, so it carries that request's trace. Other
|
|
6
|
+
handlers' output is unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from ._privacy import StructuredLog
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._client import Client
|
|
18
|
+
|
|
19
|
+
# Attributes every LogRecord has; anything else came from ``extra=``.
|
|
20
|
+
_STANDARD = frozenset(vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys() | {"message", "asctime", "taskName"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def severity_number(level: int) -> int:
|
|
24
|
+
"""OpenTelemetry's mapping of a logging level to a severity number."""
|
|
25
|
+
if level >= logging.CRITICAL:
|
|
26
|
+
return 21
|
|
27
|
+
if level >= logging.ERROR:
|
|
28
|
+
return 17
|
|
29
|
+
if level >= logging.WARNING:
|
|
30
|
+
return 13
|
|
31
|
+
if level >= logging.INFO:
|
|
32
|
+
return 9
|
|
33
|
+
if level >= logging.DEBUG:
|
|
34
|
+
return 5
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentrailHandler(logging.Handler):
|
|
39
|
+
"""Message text and ``extra`` fields leave only when named in ``logs.include_fields``.
|
|
40
|
+
|
|
41
|
+
``message`` is the formatted message. ``event.name`` (or ``event_name``) in
|
|
42
|
+
``extra`` names the event; an exception's class name becomes ``error.type``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, client: Client, level: int = logging.NOTSET) -> None:
|
|
46
|
+
super().__init__(level)
|
|
47
|
+
self._client = client
|
|
48
|
+
|
|
49
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
50
|
+
try:
|
|
51
|
+
fields: dict[str, object] = {"message": record.getMessage()}
|
|
52
|
+
for key, value in vars(record).items():
|
|
53
|
+
if key not in _STANDARD:
|
|
54
|
+
fields[key] = value
|
|
55
|
+
event = fields.get("event.name", fields.get("event_name"))
|
|
56
|
+
error = record.exc_info[1] if record.exc_info else None
|
|
57
|
+
explicit = fields.get("error.type")
|
|
58
|
+
self._client.log(
|
|
59
|
+
StructuredLog(
|
|
60
|
+
severity_number=severity_number(record.levelno),
|
|
61
|
+
severity_text=record.levelname,
|
|
62
|
+
error_type=explicit
|
|
63
|
+
if isinstance(explicit, str)
|
|
64
|
+
else (type(error).__qualname__ if error is not None else None),
|
|
65
|
+
event_name=event if isinstance(event, str) else None,
|
|
66
|
+
fields=fields,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
except Exception: # noqa: BLE001 - logging's own contract: report through handleError
|
|
70
|
+
self.handleError(record)
|
agentrail_python/mcp.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""MCP steps for the official ``mcp`` Python SDK (2.x, ``MCPServer``).
|
|
3
|
+
|
|
4
|
+
Wrap the functions you register with a stable registered name; add the
|
|
5
|
+
middleware so each step knows the calling client and protocol::
|
|
6
|
+
|
|
7
|
+
capture = mcp_capture(agentrail)
|
|
8
|
+
server = MCPServer("orders", middleware=[capture.middleware])
|
|
9
|
+
server.tool(name="checkout")(capture.tool("checkout", checkout))
|
|
10
|
+
|
|
11
|
+
Arguments, results, prompt text and resource URIs are never recorded. Client
|
|
12
|
+
details come from the client and are descriptive only: they never assert an
|
|
13
|
+
identity.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextvars
|
|
19
|
+
import functools
|
|
20
|
+
import inspect
|
|
21
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
22
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
23
|
+
|
|
24
|
+
from opentelemetry import trace
|
|
25
|
+
from opentelemetry.trace import StatusCode
|
|
26
|
+
|
|
27
|
+
from ._options import McpContext, MilestoneContext
|
|
28
|
+
from ._text import bounded
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ._client import Client
|
|
32
|
+
|
|
33
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
34
|
+
|
|
35
|
+
_request: contextvars.ContextVar[tuple[str | None, str | None, str | None] | None] = contextvars.ContextVar(
|
|
36
|
+
"agentrail_python_mcp_request", default=None
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _text(value: object, limit: int) -> str | None:
|
|
41
|
+
return bounded(value, limit) or None if isinstance(value, str) else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _span_name(method: str, name: str) -> str:
|
|
45
|
+
return bounded(f"mcp.{method}:{name}", 256)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_error(result: object) -> bool:
|
|
49
|
+
if isinstance(result, Mapping):
|
|
50
|
+
return cast(Mapping[str, object], result).get("isError") is True
|
|
51
|
+
return getattr(result, "is_error", None) is True or getattr(result, "isError", None) is True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class McpCapture:
|
|
55
|
+
def __init__(self, client: Client) -> None:
|
|
56
|
+
self._client = client
|
|
57
|
+
|
|
58
|
+
async def middleware(self, ctx: Any, call_next: Callable[[Any], Awaitable[Any]]) -> Any:
|
|
59
|
+
"""``ServerMiddleware``: keeps this request's client name, version and protocol."""
|
|
60
|
+
info = getattr(getattr(getattr(ctx, "session", None), "client_params", None), "client_info", None)
|
|
61
|
+
token = _request.set(
|
|
62
|
+
(
|
|
63
|
+
_text(getattr(info, "name", None), 128),
|
|
64
|
+
_text(getattr(info, "version", None), 128),
|
|
65
|
+
_text(getattr(ctx, "protocol_version", None), 64),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
return await call_next(ctx)
|
|
70
|
+
finally:
|
|
71
|
+
_request.reset(token)
|
|
72
|
+
|
|
73
|
+
def tool(self, name: str, handler: F) -> F:
|
|
74
|
+
"""Wrap a tool function. A result with ``isError`` marks the step failed."""
|
|
75
|
+
return self._wrap("tools/call", "tool.call", name, handler)
|
|
76
|
+
|
|
77
|
+
def resource(self, name: str, handler: F) -> F:
|
|
78
|
+
"""Wrap a resource function. Pass the registered name, never the requested URI."""
|
|
79
|
+
return self._wrap("resources/read", "mcp.resources/read", name, handler)
|
|
80
|
+
|
|
81
|
+
def prompt(self, name: str, handler: F) -> F:
|
|
82
|
+
"""Wrap a prompt function."""
|
|
83
|
+
return self._wrap("prompts/get", "mcp.prompts/get", name, handler)
|
|
84
|
+
|
|
85
|
+
def _details(self, method: str, kind: str) -> MilestoneContext:
|
|
86
|
+
client, version, protocol = _request.get() or (None, None, None)
|
|
87
|
+
return MilestoneContext(
|
|
88
|
+
surface="mcp",
|
|
89
|
+
kind=kind,
|
|
90
|
+
mcp=McpContext(method=method, client=client, client_version=version, protocol=protocol),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _wrap(self, method: str, kind: str, name: str, handler: F) -> F:
|
|
94
|
+
label = _span_name(method, name)
|
|
95
|
+
client = self._client
|
|
96
|
+
|
|
97
|
+
def mark_result(result: object) -> None:
|
|
98
|
+
if _is_error(result):
|
|
99
|
+
span = trace.get_current_span()
|
|
100
|
+
span.set_status(StatusCode.ERROR)
|
|
101
|
+
span.set_attribute("error.type", "McpOperationError")
|
|
102
|
+
|
|
103
|
+
if inspect.iscoroutinefunction(handler):
|
|
104
|
+
|
|
105
|
+
@functools.wraps(handler)
|
|
106
|
+
async def run_async(*args: Any, **kwargs: Any) -> Any:
|
|
107
|
+
async with client.start_milestone(label, self._details(method, kind)):
|
|
108
|
+
result = await handler(*args, **kwargs)
|
|
109
|
+
mark_result(result)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
return cast(F, run_async)
|
|
113
|
+
|
|
114
|
+
@functools.wraps(handler)
|
|
115
|
+
def run(*args: Any, **kwargs: Any) -> Any:
|
|
116
|
+
with client.start_milestone(label, self._details(method, kind)):
|
|
117
|
+
result = handler(*args, **kwargs)
|
|
118
|
+
mark_result(result)
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
return cast(F, run)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def mcp_capture(client: Client) -> McpCapture:
|
|
125
|
+
return McpCapture(client)
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""A requests adapter that signs trace context only for allowed origins.
|
|
3
|
+
|
|
4
|
+
``session.mount("https://", agentrail_python.requests.adapter(agentrail))``.
|
|
5
|
+
Each redirect is sent through the adapter again, so every actual destination is
|
|
6
|
+
admitted on its own. The call's step ends when ``send`` returns: with
|
|
7
|
+
``stream=True`` it does not wait for the body.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from requests import PreparedRequest, Response
|
|
16
|
+
from requests.adapters import HTTPAdapter
|
|
17
|
+
|
|
18
|
+
from ._handoff import OWNED_HEADERS
|
|
19
|
+
from ._outbound import begin, finish, origin
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ._client import Client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentrailAdapter(HTTPAdapter):
|
|
26
|
+
def __init__(self, client: Client, **kwargs: Any) -> None:
|
|
27
|
+
super().__init__(**kwargs)
|
|
28
|
+
self._agentrail = client
|
|
29
|
+
|
|
30
|
+
def send(
|
|
31
|
+
self,
|
|
32
|
+
request: PreparedRequest,
|
|
33
|
+
stream: bool = False,
|
|
34
|
+
timeout: Any = None,
|
|
35
|
+
verify: bool | str = True,
|
|
36
|
+
cert: Any = None,
|
|
37
|
+
proxies: Any = None,
|
|
38
|
+
) -> Response:
|
|
39
|
+
url = request.url or ""
|
|
40
|
+
parts = urlsplit(url)
|
|
41
|
+
target = request.path_url
|
|
42
|
+
outgoing = begin(
|
|
43
|
+
self._agentrail,
|
|
44
|
+
request.method or "GET",
|
|
45
|
+
origin(parts.scheme, parts.hostname or "", parts.port),
|
|
46
|
+
target.split("?", 1)[0],
|
|
47
|
+
target,
|
|
48
|
+
url,
|
|
49
|
+
)
|
|
50
|
+
if outgoing.strip:
|
|
51
|
+
for name in OWNED_HEADERS:
|
|
52
|
+
request.headers.pop(name, None)
|
|
53
|
+
request.headers.update(outgoing.headers)
|
|
54
|
+
if outgoing.span is None:
|
|
55
|
+
return super().send(request, stream, timeout, verify, cert, proxies)
|
|
56
|
+
try:
|
|
57
|
+
response = super().send(request, stream, timeout, verify, cert, proxies)
|
|
58
|
+
except BaseException as error:
|
|
59
|
+
finish(outgoing.span, None, error)
|
|
60
|
+
raise
|
|
61
|
+
finish(outgoing.span, response.status_code, None)
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def adapter(client: Client, **kwargs: Any) -> AgentrailAdapter:
|
|
66
|
+
"""An ``HTTPAdapter`` to mount on a ``requests.Session``; ``kwargs`` go to ``HTTPAdapter``."""
|
|
67
|
+
return AgentrailAdapter(client, **kwargs)
|
agentrail_python/wsgi.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""WSGI middleware: Flask, Django (WSGI), Falcon, Pyramid, Bottle."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextvars
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from opentelemetry import context as otel_context
|
|
13
|
+
from opentelemetry.trace import Span, StatusCode
|
|
14
|
+
|
|
15
|
+
from ._options import IncomingRequest
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ._client import Client
|
|
19
|
+
|
|
20
|
+
StartResponse = Callable[..., Any]
|
|
21
|
+
WSGIApp = Callable[[dict[str, Any], StartResponse], Iterable[bytes]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _request(environ: Mapping[str, Any]) -> IncomingRequest:
|
|
25
|
+
headers: dict[str, str] = {}
|
|
26
|
+
for key, value in environ.items():
|
|
27
|
+
if key.startswith("HTTP_") and isinstance(value, str):
|
|
28
|
+
headers[key[5:].replace("_", "-").lower()] = value
|
|
29
|
+
for key in ("CONTENT_TYPE", "CONTENT_LENGTH"):
|
|
30
|
+
value = environ.get(key)
|
|
31
|
+
if isinstance(value, str) and value:
|
|
32
|
+
headers[key.replace("_", "-").lower()] = value
|
|
33
|
+
# Servers that keep the request line as sent (gunicorn, Werkzeug, uWSGI) give
|
|
34
|
+
# the exact target a handoff was signed over; PATH_INFO is already decoded.
|
|
35
|
+
raw = environ.get("RAW_URI") or environ.get("REQUEST_URI")
|
|
36
|
+
if isinstance(raw, str) and raw.startswith("/"):
|
|
37
|
+
target = raw
|
|
38
|
+
else:
|
|
39
|
+
path = quote(
|
|
40
|
+
str(environ.get("SCRIPT_NAME", "")) + str(environ.get("PATH_INFO", "/")),
|
|
41
|
+
safe="/%:@!$&'()*+,;=~",
|
|
42
|
+
)
|
|
43
|
+
query = str(environ.get("QUERY_STRING", ""))
|
|
44
|
+
target = f"{path}?{query}" if query else path
|
|
45
|
+
scheme = str(environ.get("wsgi.url_scheme", "http"))
|
|
46
|
+
host = headers.get("host") or f"{environ.get('SERVER_NAME', 'localhost')}:{environ.get('SERVER_PORT', '80')}"
|
|
47
|
+
address = environ.get("REMOTE_ADDR")
|
|
48
|
+
return IncomingRequest(
|
|
49
|
+
method=str(environ.get("REQUEST_METHOD", "GET")),
|
|
50
|
+
target=target,
|
|
51
|
+
scheme=scheme,
|
|
52
|
+
host=host,
|
|
53
|
+
headers=headers,
|
|
54
|
+
client=address if isinstance(address, str) and address else None,
|
|
55
|
+
raw=environ,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Body:
|
|
60
|
+
"""The response body; the entry span ends when the server closes it.
|
|
61
|
+
|
|
62
|
+
A streamed body runs after the application returned, in the context the
|
|
63
|
+
application ran in: its logs, milestones and outgoing calls belong to the
|
|
64
|
+
request, and a span it opens across chunks stays current between them. That
|
|
65
|
+
context is the body's own, so nothing it sets reaches the server's.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
body: Iterable[bytes],
|
|
71
|
+
finish: Callable[[BaseException | None], None],
|
|
72
|
+
run: contextvars.Context,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._body = body
|
|
75
|
+
self._finish = finish
|
|
76
|
+
self._run = run
|
|
77
|
+
|
|
78
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
79
|
+
try:
|
|
80
|
+
chunks = self._run.run(iter, self._body)
|
|
81
|
+
except BaseException as error:
|
|
82
|
+
self._finish(error)
|
|
83
|
+
raise
|
|
84
|
+
while True:
|
|
85
|
+
try:
|
|
86
|
+
chunk = self._run.run(next, chunks)
|
|
87
|
+
except StopIteration:
|
|
88
|
+
return
|
|
89
|
+
except BaseException as error:
|
|
90
|
+
self._finish(error)
|
|
91
|
+
raise
|
|
92
|
+
yield chunk
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
close = getattr(self._body, "close", None)
|
|
96
|
+
if callable(close):
|
|
97
|
+
try:
|
|
98
|
+
self._run.run(close)
|
|
99
|
+
except BaseException as error:
|
|
100
|
+
self._finish(error)
|
|
101
|
+
raise
|
|
102
|
+
self._finish(None)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AgentrailWSGIMiddleware:
|
|
106
|
+
"""Records each HTTP request the wrapped application serves as a journey step.
|
|
107
|
+
|
|
108
|
+
``app.wsgi_app = agentrail.wsgi(app.wsgi_app)`` in Flask, or
|
|
109
|
+
``application = agentrail.wsgi(get_wsgi_application())`` in Django.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, app: WSGIApp, *, client: Client) -> None:
|
|
113
|
+
self.app = app
|
|
114
|
+
self._client = client
|
|
115
|
+
|
|
116
|
+
def __call__(self, environ: dict[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
|
117
|
+
entered = self._client._enter(_request(environ)) # pyright: ignore[reportPrivateUsage]
|
|
118
|
+
if entered is None:
|
|
119
|
+
return self.app(environ, start_response)
|
|
120
|
+
span, ctx = entered
|
|
121
|
+
status: list[int] = []
|
|
122
|
+
ended = threading.Lock()
|
|
123
|
+
|
|
124
|
+
def finish(error: BaseException | None) -> None:
|
|
125
|
+
if not ended.acquire(blocking=False):
|
|
126
|
+
return
|
|
127
|
+
_end(span, status, error)
|
|
128
|
+
|
|
129
|
+
def observed_start(status_line: str, headers: Any, exc_info: Any = None) -> Any:
|
|
130
|
+
code = status_line.split(" ", 1)[0]
|
|
131
|
+
if code.isdigit():
|
|
132
|
+
status[:] = [int(code)]
|
|
133
|
+
return start_response(status_line, headers, exc_info) if exc_info else start_response(status_line, headers)
|
|
134
|
+
|
|
135
|
+
# The application and its body run in one context of their own, holding
|
|
136
|
+
# the request: what they set carries from the call to every chunk.
|
|
137
|
+
token = otel_context.attach(ctx)
|
|
138
|
+
try:
|
|
139
|
+
run = contextvars.copy_context()
|
|
140
|
+
finally:
|
|
141
|
+
otel_context.detach(token)
|
|
142
|
+
try:
|
|
143
|
+
body = run.run(self.app, environ, observed_start)
|
|
144
|
+
except BaseException as error:
|
|
145
|
+
finish(error)
|
|
146
|
+
raise
|
|
147
|
+
return _Body(body, finish, run)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _end(span: Span, status: list[int], error: BaseException | None) -> None:
|
|
151
|
+
if error is not None:
|
|
152
|
+
span.set_status(StatusCode.ERROR)
|
|
153
|
+
span.set_attribute("error.type", type(error).__qualname__)
|
|
154
|
+
if status:
|
|
155
|
+
span.set_attribute("http.response.status_code", status[0])
|
|
156
|
+
if status[0] >= 500:
|
|
157
|
+
span.set_status(StatusCode.ERROR)
|
|
158
|
+
span.end()
|