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.
@@ -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)
@@ -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)
@@ -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()