webagent-protocol 0.2.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,305 @@
1
+ """Serve a WAPServer's capabilities as a standard MCP server (streamable HTTP) at ``/mcp``.
2
+
3
+ This is what makes WAP an *extension* of the Model Context Protocol rather than
4
+ a competitor: the same ``@wap.action`` functions are exposed twice,
5
+
6
+ * as WAP capabilities (signed envelopes, proof-of-work, SSE) at ``/wap/v1/interact``, and
7
+ * as ordinary MCP tools at ``/mcp``, usable by any MCP client with no WAP code.
8
+
9
+ The MCP endpoint keeps WAP's guarantees where MCP has room for them:
10
+
11
+ * every successful ``tools/call`` result carries the business's Ed25519-signed
12
+ reply in ``_meta["io.webagent/signed_reply"]``, verifiable against the key in
13
+ ``/.well-known/wap.json``;
14
+ * calls are rate-limited per client IP with the server's policy;
15
+ * proof-of-work can be required (``mcp_require_pow``): the client passes
16
+ ``_meta["io.webagent/pow"] = {"seed", "nonce"}`` and a failed call returns a
17
+ fresh challenge in ``_meta["io.webagent/challenge"]``;
18
+ * multi-turn state continues via ``_meta["io.webagent/session_id"]``.
19
+
20
+ Installed with the ``server`` extra (``pip install "webagent-protocol[server]"``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import uuid
27
+ from collections.abc import AsyncIterator
28
+ from contextlib import asynccontextmanager
29
+ from typing import TYPE_CHECKING, Any
30
+ from urllib.parse import urlsplit
31
+
32
+ import mcp_types as types
33
+ from mcp.server.lowlevel import Server
34
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
35
+ from mcp.server.transport_security import TransportSecuritySettings
36
+ from mcp.shared.exceptions import MCPError
37
+
38
+ from .. import __version__
39
+ from ..spec.models import (
40
+ WAP_VERSION,
41
+ WELL_KNOWN_PATH,
42
+ AgentMessage,
43
+ ErrorCode,
44
+ Role,
45
+ authority_host,
46
+ mcp_annotation_hints,
47
+ )
48
+ from .admission import AdmissionRequest
49
+ from .app import WAPProtocolError
50
+
51
+ if TYPE_CHECKING:
52
+ from fastapi import FastAPI
53
+
54
+ from .app import WAPServer
55
+
56
+ EXTENSION_ID = "io.webagent/wap"
57
+ META_PREFIX = "io.webagent/"
58
+ META_SIGNED_REPLY = META_PREFIX + "signed_reply"
59
+ META_SESSION_ID = META_PREFIX + "session_id"
60
+ META_POW = META_PREFIX + "pow"
61
+ META_IDEMPOTENCY_KEY = META_PREFIX + "idempotency_key"
62
+ META_CHALLENGE = META_PREFIX + "challenge"
63
+ META_ERROR = META_PREFIX + "error"
64
+ META_MANIFEST = META_PREFIX + "manifest_url"
65
+ MCP_SESSION_OWNER = "mcp"
66
+ INVALID_PARAMS = -32602
67
+
68
+
69
+ def _protocol_error_result(exc: WAPProtocolError) -> types.CallToolResult:
70
+ extra: dict[str, Any] = {}
71
+ details = dict(exc.details or {})
72
+ challenge = details.pop("challenge", None)
73
+ if challenge is not None:
74
+ extra[META_CHALLENGE] = challenge
75
+ if details:
76
+ extra[META_PREFIX + "details"] = details
77
+ if exc.retry_after is not None:
78
+ extra[META_PREFIX + "retry_after"] = exc.retry_after
79
+ message = exc.message
80
+ if exc.code is ErrorCode.POW_REQUIRED:
81
+ message = (
82
+ f"this tool requires proof-of-work: solve _meta['{META_CHALLENGE}'] and resend with "
83
+ f"_meta['{META_POW}'] = {{'seed': ..., 'nonce': ...}}"
84
+ )
85
+ return _error_result(exc.code.value, message, extra)
86
+
87
+
88
+ def _error_result(code: str, message: str, extra_meta: dict[str, Any] | None = None) -> types.CallToolResult:
89
+ return types.CallToolResult(
90
+ content=[types.TextContent(type="text", text=f"[{code}] {message}")],
91
+ is_error=True,
92
+ meta={META_ERROR: {"code": code, "message": message}, **(extra_meta or {})},
93
+ )
94
+
95
+
96
+ class MCPEndpoint:
97
+ """Bridges one :class:`WAPServer` onto an MCP ``Server`` served over streamable HTTP."""
98
+
99
+ def __init__(self, server: WAPServer, path: str = "/mcp") -> None:
100
+ self.wap = server
101
+ self.path = path
102
+ self.mcp = Server(
103
+ server.name,
104
+ version=__version__,
105
+ description=server.description or None,
106
+ instructions=(
107
+ f"Tools published by {server.name} ({server.domain}) via the WebAgent Protocol. "
108
+ f"Results are signed; verify _meta['{META_SIGNED_REPLY}'] against the public key at "
109
+ f"{server.base_url}{WELL_KNOWN_PATH}."
110
+ ),
111
+ on_list_tools=self._list_tools,
112
+ on_call_tool=self._call_tool,
113
+ )
114
+ # Declared as an MCP extension (SEP-2133) so clients can detect WAP support at initialization.
115
+ self.mcp.extensions[EXTENSION_ID] = self.extension_settings()
116
+ self._manager: StreamableHTTPSessionManager | None = None
117
+
118
+ def extension_settings(self) -> dict[str, Any]:
119
+ return {
120
+ "wap_version": WAP_VERSION,
121
+ "manifest_url": self.wap.base_url + WELL_KNOWN_PATH,
122
+ "public_key": self.wap.public_key,
123
+ "pow_required": self.require_pow,
124
+ "signed_results": True,
125
+ "conversation_policy": self.wap.conversation_policy.as_dict(),
126
+ }
127
+
128
+ # ------------------------------------------------------------------ MCP handlers
129
+
130
+ def tools(self) -> list[types.Tool]:
131
+ tools = []
132
+ for action in self.wap.actions.values():
133
+ cap = action.capability
134
+ output = cap.output_schema if cap.output_schema and cap.output_schema.get("type") == "object" else None
135
+ tools.append(
136
+ types.Tool(
137
+ name=cap.id,
138
+ title=cap.name,
139
+ description=cap.description or cap.name,
140
+ input_schema=cap.input_schema,
141
+ output_schema=output,
142
+ annotations=types.ToolAnnotations(title=cap.name, **mcp_annotation_hints(cap)),
143
+ meta={META_PREFIX + "requires_auth": cap.requires_auth, META_PREFIX + "effects": cap.effects},
144
+ )
145
+ )
146
+ return tools
147
+
148
+ async def _list_tools(self, ctx: Any, params: Any) -> types.ListToolsResult:
149
+ return types.ListToolsResult(tools=self.tools())
150
+
151
+ @property
152
+ def require_pow(self) -> bool:
153
+ configured = self.wap.mcp_require_pow
154
+ return self.wap.require_pow if configured is None else configured
155
+
156
+ async def _call_tool(self, ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
157
+ server = self.wap
158
+ if params.name not in server.actions:
159
+ raise MCPError(INVALID_PARAMS, f"unknown tool {params.name!r}", {"available": sorted(server.actions)})
160
+ request = getattr(ctx, "request", None)
161
+ ip = request.client.host if request is not None and getattr(request, "client", None) else None
162
+ headers = request.headers if request is not None else {}
163
+ meta: dict[str, Any] = dict(params.meta or {})
164
+ solution = meta.get(META_POW) if isinstance(meta.get(META_POW), dict) else {}
165
+
166
+ try:
167
+ await server.limit_ip(ip)
168
+ principal = await server.authenticate(headers.get("authorization"))
169
+ decision, _ = await server.admit(
170
+ AdmissionRequest(
171
+ client_ip=ip,
172
+ agent_key=None,
173
+ principal=principal,
174
+ capability_id=params.name,
175
+ transport="mcp",
176
+ headers=dict(headers),
177
+ ),
178
+ pow_seed=str(solution["seed"]) if "seed" in solution else None,
179
+ pow_nonce=str(solution["nonce"]) if "nonce" in solution else None,
180
+ )
181
+ except WAPProtocolError as exc:
182
+ return _protocol_error_result(exc)
183
+
184
+ session_id = str(meta.get(META_SESSION_ID) or "mcp-" + uuid.uuid4().hex)
185
+ idempotency_key = meta.get(META_IDEMPOTENCY_KEY)
186
+ message = AgentMessage(
187
+ session_id=session_id,
188
+ role=Role.USER_AGENT,
189
+ capability_id=params.name,
190
+ structured_data=dict(params.arguments or {}),
191
+ idempotency_key=str(idempotency_key) if idempotency_key else None,
192
+ )
193
+ # Per-session loop guard only: an IP may be shared by many unrelated MCP users.
194
+ turn = server.open_turn(
195
+ message,
196
+ owner_key=MCP_SESSION_OWNER,
197
+ principal_key=None,
198
+ client_ip=ip,
199
+ principal=principal,
200
+ tier=decision.tier,
201
+ conversation_policy=decision.conversation_policy,
202
+ )
203
+ try:
204
+ await turn.open()
205
+ if turn.replay is not None:
206
+ return self._result(turn.replay, session_id, replayed=True)
207
+ assert turn.ctx is not None
208
+ text: list[str] = []
209
+ data: dict[str, Any] = {}
210
+ async for chunk in server.dispatch(turn.ctx):
211
+ if isinstance(chunk, str):
212
+ text.append(chunk)
213
+ else:
214
+ data.update(chunk)
215
+ reply = await turn.complete("".join(text), data or None)
216
+ except WAPProtocolError as exc:
217
+ await turn.fail(exc)
218
+ return _protocol_error_result(exc)
219
+ finally:
220
+ await turn.close()
221
+ return self._result(reply, session_id)
222
+
223
+ def _result(self, reply: AgentMessage, session_id: str, *, replayed: bool = False) -> types.CallToolResult:
224
+ data = reply.structured_data or {}
225
+ content = [types.TextContent(type="text", text=reply.content or json.dumps(data))]
226
+ if data and reply.content:
227
+ content.append(types.TextContent(type="text", text=json.dumps(data, ensure_ascii=False)))
228
+ meta = {
229
+ META_SIGNED_REPLY: reply.model_dump(mode="json"),
230
+ META_SESSION_ID: session_id,
231
+ META_MANIFEST: self.wap.base_url + WELL_KNOWN_PATH,
232
+ }
233
+ if replayed:
234
+ meta[META_PREFIX + "idempotent_replay"] = True
235
+ return types.CallToolResult(content=content, structured_content=data or None, meta=meta)
236
+
237
+ # ------------------------------------------------------------------ ASGI
238
+
239
+ def security_settings(self) -> TransportSecuritySettings:
240
+ hosts = {self.wap.domain, authority_host(self.wap.domain)}
241
+ base = urlsplit(self.wap.base_url)
242
+ hosts.update({base.netloc, base.hostname or ""})
243
+ allowed = sorted(h for h in hosts if h)
244
+ allowed += [f"{authority_host(h)}:*" for h in allowed if ":" not in h]
245
+ return TransportSecuritySettings(
246
+ enable_dns_rebinding_protection=True,
247
+ allowed_hosts=sorted(set(allowed)),
248
+ allowed_origins=sorted({f"{base.scheme}://{base.netloc}", f"https://{self.wap.domain}"}),
249
+ )
250
+
251
+ @asynccontextmanager
252
+ async def running(self) -> AsyncIterator[None]:
253
+ """Run the MCP session manager; entered from the host application's lifespan."""
254
+ self.mcp.extensions[EXTENSION_ID] = self.extension_settings() # pick up config changed after mount
255
+ manager = StreamableHTTPSessionManager(
256
+ app=self.mcp, json_response=True, stateless=True, security_settings=self.security_settings()
257
+ )
258
+ async with manager.run():
259
+ self._manager = manager
260
+ try:
261
+ yield
262
+ finally:
263
+ self._manager = None
264
+
265
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
266
+ if self._manager is None:
267
+ body = b'{"error":"MCP endpoint is not running; start the app with its ASGI lifespan enabled"}'
268
+ await send(
269
+ {
270
+ "type": "http.response.start",
271
+ "status": 503,
272
+ "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
273
+ }
274
+ )
275
+ await send({"type": "http.response.body", "body": body})
276
+ return
277
+ await self._manager.handle_request(scope, receive, send)
278
+
279
+
280
+ def mount_mcp(app: FastAPI, server: WAPServer, path: str = "/mcp") -> MCPEndpoint:
281
+ """Add the MCP endpoint to ``app`` and hook its session manager into the app lifespan."""
282
+ endpoint = MCPEndpoint(server, path)
283
+ app.router.add_route(path, endpoint, methods=["GET", "POST", "DELETE"], include_in_schema=False)
284
+ original = app.router.lifespan_context
285
+
286
+ @asynccontextmanager
287
+ async def lifespan(asgi_app: Any) -> AsyncIterator[Any]:
288
+ async with endpoint.running():
289
+ async with original(asgi_app) as state:
290
+ yield state
291
+
292
+ app.router.lifespan_context = lifespan
293
+ return endpoint
294
+
295
+
296
+ __all__ = [
297
+ "EXTENSION_ID",
298
+ "META_CHALLENGE",
299
+ "META_IDEMPOTENCY_KEY",
300
+ "META_POW",
301
+ "META_SESSION_ID",
302
+ "META_SIGNED_REPLY",
303
+ "MCPEndpoint",
304
+ "mount_mcp",
305
+ ]
@@ -0,0 +1,122 @@
1
+ """Publish an existing MCP server's tools on the open web through WAP.
2
+
3
+ If you already have an MCP server, one call makes its tools discoverable at
4
+ ``/.well-known/wap.json``, with signed replies, proof-of-work and rate limits::
5
+
6
+ from mcp.server.mcpserver import MCPServer
7
+ from wap.server import WAPServer
8
+
9
+ tools = MCPServer("inventory")
10
+
11
+ @tools.tool()
12
+ def check_stock(sku: str) -> dict: ...
13
+
14
+ wap = WAPServer(name="Acme", domain="acme.example", private_key=KEY)
15
+ wap.include_mcp(tools) # imported when the app starts
16
+ app = wap.create_app()
17
+
18
+ ``source`` is anything the MCP SDK's ``Client`` accepts: an in-process server
19
+ object, a streamable-HTTP URL, or ``StdioServerParameters`` for a subprocess.
20
+ Installed with the ``server`` extra.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ from contextlib import AsyncExitStack
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ from mcp.client.client import Client
31
+
32
+ from ..spec.models import Capability, ErrorCode
33
+ from .app import ActionContext, ActionResult, WAPProtocolError
34
+
35
+ if TYPE_CHECKING:
36
+ from .app import WAPServer
37
+
38
+ _INVALID = re.compile(r"[^a-z0-9_.-]+")
39
+
40
+
41
+ def capability_id_for(tool_name: str, prefix: str = "") -> str:
42
+ """Map an MCP tool name onto WAP's capability-id grammar (``^[a-z][a-z0-9_.-]{0,63}$``)."""
43
+ ident = _INVALID.sub("_", (prefix + tool_name).lower()).strip("_") or "tool"
44
+ if not ident[0].isalpha():
45
+ ident = "t_" + ident
46
+ return ident[:64]
47
+
48
+
49
+ def _text_of(content: list[Any]) -> str:
50
+ return "\n".join(block.text for block in content if getattr(block, "type", None) == "text")
51
+
52
+
53
+ class MCPToolSource:
54
+ """A live connection to an MCP server whose tools are re-published as WAP capabilities."""
55
+
56
+ def __init__(self, source: Any, *, prefix: str = "", include: set[str] | None = None) -> None:
57
+ self.source = source
58
+ self.prefix = prefix
59
+ self.include = include
60
+ self.client: Client | None = None
61
+ self._stack: AsyncExitStack | None = None
62
+ self.capability_ids: list[str] = []
63
+
64
+ async def connect(self) -> Client:
65
+ if self.client is None:
66
+ stack = AsyncExitStack()
67
+ self.client = await stack.enter_async_context(Client(self.source))
68
+ self._stack = stack
69
+ return self.client
70
+
71
+ async def aclose(self) -> None:
72
+ if self._stack is not None:
73
+ await self._stack.aclose()
74
+ self._stack = None
75
+ self.client = None
76
+
77
+ async def register(self, wap: WAPServer) -> list[str]:
78
+ """List the source's tools and add each one to ``wap`` as a capability."""
79
+ client = await self.connect()
80
+ registered = []
81
+ for tool in (await client.list_tools()).tools:
82
+ if self.include is not None and tool.name not in self.include:
83
+ continue
84
+ capability_id = capability_id_for(tool.name, self.prefix)
85
+ if capability_id in wap.actions:
86
+ continue
87
+ schema = dict(tool.input_schema or {"type": "object", "properties": {}})
88
+ schema.setdefault("type", "object")
89
+ output = tool.output_schema if tool.output_schema and tool.output_schema.get("type") == "object" else None
90
+ capability = Capability(
91
+ id=capability_id,
92
+ name=(tool.title or tool.name)[:128],
93
+ description=(tool.description or "")[:4096],
94
+ input_schema=schema,
95
+ output_schema=output,
96
+ )
97
+ wap.add_capability(capability, self._handler(tool.name))
98
+ registered.append(capability_id)
99
+ self.capability_ids = registered
100
+ return registered
101
+
102
+ def _handler(self, tool_name: str) -> Any:
103
+ async def call(payload: dict[str, Any], ctx: ActionContext) -> ActionResult:
104
+ if self.client is None:
105
+ raise WAPProtocolError(ErrorCode.ACTION_FAILED, "the upstream MCP server is not connected")
106
+ result = await self.client.call_tool(tool_name, payload)
107
+ text = _text_of(list(result.content or []))
108
+ if result.is_error:
109
+ raise WAPProtocolError(ErrorCode.ACTION_FAILED, text or f"MCP tool {tool_name!r} failed")
110
+ data = result.structured_content
111
+ if data is None:
112
+ # Tools without an output schema (e.g. ``-> dict``) return their result only as JSON text.
113
+ try:
114
+ data = json.loads(text)
115
+ except ValueError:
116
+ data = None
117
+ return ActionResult(content=text, data=dict(data) if isinstance(data, dict) else None)
118
+
119
+ return call
120
+
121
+
122
+ __all__ = ["MCPToolSource", "capability_id_for"]
@@ -0,0 +1,111 @@
1
+ """Attach WAP to existing applications.
2
+
3
+ * :func:`inject_routes` is what :meth:`WAPServer.mount` uses for FastAPI apps:
4
+ it adds the discovery, challenge and interaction routes and stamps every
5
+ WAP response with ``X-WAP-Version``.
6
+ * :class:`WAPDiscoveryMiddleware` is a dependency-light pure ASGI middleware
7
+ for *any* ASGI framework (Starlette, Django, Quart, Litestar...). It serves
8
+ ``/.well-known/wap.json`` (and the legacy ``/.well-known/agent.json``) in front of the wrapped application, so a site
9
+ can publish its manifest without routing changes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from ..spec.crypto import canonical_json
17
+ from ..spec.models import HEADER_SIGNATURE, HEADER_VERSION, WAP_VERSION, WELL_KNOWN_PATHS, ErrorResponse
18
+ from .app import WAPProtocolError
19
+
20
+ if TYPE_CHECKING:
21
+ from fastapi import FastAPI
22
+
23
+ from .app import WAPServer
24
+
25
+ Scope = dict[str, Any]
26
+ ASGIApp = Any
27
+
28
+
29
+ class WAPDiscoveryMiddleware:
30
+ """Serve the signed manifest at the well-known paths in front of any ASGI app."""
31
+
32
+ def __init__(self, app: ASGIApp, server: WAPServer) -> None:
33
+ self.app = app
34
+ self.server = server
35
+
36
+ async def __call__(self, scope: Scope, receive: Any, send: Any) -> None:
37
+ if scope["type"] == "http" and scope.get("path") in WELL_KNOWN_PATHS:
38
+ method = scope.get("method", "GET")
39
+ if method in ("GET", "HEAD"):
40
+ await self._serve_manifest(scope, send, head=method == "HEAD")
41
+ return
42
+ if method == "OPTIONS":
43
+ await self._send(
44
+ send,
45
+ 204,
46
+ b"",
47
+ {
48
+ "Allow": "GET, HEAD, OPTIONS",
49
+ "Access-Control-Allow-Origin": "*",
50
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
51
+ },
52
+ )
53
+ return
54
+ await self._send(send, 405, b"", {"Allow": "GET, HEAD, OPTIONS"})
55
+ return
56
+ await self.app(scope, receive, send)
57
+
58
+ async def _serve_manifest(self, scope: Scope, send: Any, head: bool) -> None:
59
+ client = scope.get("client")
60
+ ip = client[0] if client else None
61
+ try:
62
+ await self.server.limit_ip(ip)
63
+ except WAPProtocolError as exc:
64
+ body = canonical_json(
65
+ ErrorResponse.build(exc.code, exc.message, retry_after=exc.retry_after).model_dump(
66
+ mode="json", exclude_none=True
67
+ )
68
+ )
69
+ headers = {
70
+ "Content-Type": "application/json",
71
+ **exc.headers,
72
+ HEADER_SIGNATURE: self.server.signer.sign(body),
73
+ }
74
+ await self._send(send, 429, body, headers)
75
+ return
76
+ manifest = self.server.manifest()
77
+ body = canonical_json(manifest.model_dump(mode="json"))
78
+ headers = {
79
+ "Content-Type": "application/json",
80
+ "Cache-Control": "public, max-age=300",
81
+ "Access-Control-Allow-Origin": "*",
82
+ "ETag": f'"{manifest.signature[:32]}"',
83
+ HEADER_SIGNATURE: self.server.signer.sign(body),
84
+ }
85
+ await self._send(send, 200, b"" if head else body, headers, content_length=len(body))
86
+
87
+ @staticmethod
88
+ async def _send(
89
+ send: Any, status: int, body: bytes, headers: dict[str, str], content_length: int | None = None
90
+ ) -> None:
91
+ raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()]
92
+ raw_headers.append((HEADER_VERSION.lower().encode(), WAP_VERSION.encode()))
93
+ raw_headers.append((b"content-length", str(len(body) if content_length is None else content_length).encode()))
94
+ await send({"type": "http.response.start", "status": status, "headers": raw_headers})
95
+ await send({"type": "http.response.body", "body": body})
96
+
97
+
98
+ def inject_routes(app: FastAPI, server: WAPServer) -> None:
99
+ """Register WAP endpoints on ``app`` and expose the server as ``app.state.wap_server``."""
100
+ from .router import build_router
101
+
102
+ existing = getattr(app.state, "wap_server", None)
103
+ if existing is not None and existing is not server:
104
+ raise RuntimeError("a different WAPServer is already mounted on this application")
105
+ if existing is server:
106
+ return
107
+ app.include_router(build_router(server))
108
+ app.state.wap_server = server
109
+
110
+
111
+ __all__ = ["WAPDiscoveryMiddleware", "inject_routes"]
@@ -0,0 +1,102 @@
1
+ """Ready-made observers for ``WAPServer(observer=...)``.
2
+
3
+ A WAP server reports what happens to each request as ``observer(event, attributes)``:
4
+
5
+ ========================== ==========================================================
6
+ ``request.completed`` ``capability``, ``tier``, ``duration_ms``
7
+ ``request.failed`` ``code``, ``capability``, ``tier`` (the action raised)
8
+ ``request.rejected`` ``code`` (``rate_limited``, ``pow_invalid``, ``loop_detected``,
9
+ ``forbidden``, ...), plus ``scope``/``tier``/``reason`` when known
10
+ ``request.idempotent_replay`` ``capability``
11
+ ``pow.issued`` ``difficulty``
12
+ ========================== ==========================================================
13
+
14
+ Use :class:`LoggingObserver` for structured logs, :class:`MetricsObserver` for
15
+ in-process counters and latency percentiles (e.g. to export to Prometheus or
16
+ OpenTelemetry), or :func:`combine` to send events to several observers. Any
17
+ callable with the same signature works; observer errors are logged and never
18
+ affect requests.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import math
26
+ import threading
27
+ from collections import Counter, defaultdict, deque
28
+ from collections.abc import Callable
29
+ from typing import Any
30
+
31
+ Observer = Callable[[str, dict[str, Any]], None]
32
+
33
+
34
+ class LoggingObserver:
35
+ """Emit one structured log line (JSON attributes) per event."""
36
+
37
+ def __init__(self, logger: logging.Logger | None = None, level: int = logging.INFO) -> None:
38
+ self.logger = logger or logging.getLogger("wap.events")
39
+ self.level = level
40
+
41
+ def __call__(self, event: str, attributes: dict[str, Any]) -> None:
42
+ level = logging.WARNING if event == "request.rejected" else self.level
43
+ self.logger.log(level, "%s %s", event, json.dumps(attributes, default=str, sort_keys=True))
44
+
45
+
46
+ class MetricsObserver:
47
+ """Thread-safe counters by (event, code/tier/capability) plus latency percentiles per capability."""
48
+
49
+ def __init__(self, max_samples: int = 10_000) -> None:
50
+ self._lock = threading.Lock()
51
+ self.counters: Counter[tuple[str, str]] = Counter()
52
+ self._latency: dict[str, deque[float]] = defaultdict(lambda: deque(maxlen=max_samples))
53
+
54
+ def __call__(self, event: str, attributes: dict[str, Any]) -> None:
55
+ label = str(attributes.get("code") or attributes.get("capability") or attributes.get("difficulty") or "")
56
+ with self._lock:
57
+ self.counters[(event, label)] += 1
58
+ if event == "request.completed" and "duration_ms" in attributes:
59
+ self._latency[str(attributes.get("capability"))].append(float(attributes["duration_ms"]))
60
+
61
+ def count(self, event: str, label: str | None = None) -> int:
62
+ with self._lock:
63
+ if label is not None:
64
+ return self.counters[(event, label)]
65
+ return sum(v for (e, _), v in self.counters.items() if e == event)
66
+
67
+ def percentile(self, capability: str, q: float) -> float | None:
68
+ with self._lock:
69
+ samples = sorted(self._latency.get(capability, ()))
70
+ if not samples:
71
+ return None
72
+ # Nearest-rank method.
73
+ rank = max(1, math.ceil(q / 100 * len(samples)))
74
+ return samples[min(rank, len(samples)) - 1]
75
+
76
+ def snapshot(self) -> dict[str, Any]:
77
+ """A JSON-friendly view, e.g. for a ``/metrics`` endpoint of your own."""
78
+ with self._lock:
79
+ counters = {f"{event}{{{label}}}" if label else event: n for (event, label), n in self.counters.items()}
80
+ capabilities = list(self._latency)
81
+ return {
82
+ "counters": counters,
83
+ "latency_ms": {
84
+ cap: {q: self.percentile(cap, float(q[1:])) for q in ("p50", "p95", "p99")} for cap in capabilities
85
+ },
86
+ }
87
+
88
+
89
+ def combine(*observers: Observer) -> Observer:
90
+ """Fan one event out to several observers."""
91
+
92
+ def observe(event: str, attributes: dict[str, Any]) -> None:
93
+ for observer in observers:
94
+ try:
95
+ observer(event, attributes)
96
+ except Exception: # noqa: BLE001 - one failing sink must not starve the others
97
+ logging.getLogger("wap.server").exception("observer %r failed for %r", observer, event)
98
+
99
+ return observe
100
+
101
+
102
+ __all__ = ["LoggingObserver", "MetricsObserver", "Observer", "combine"]