-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add auto-event emission to CapiscioMiddleware #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -58,6 +58,30 @@ app.add_middleware(CapiscioMiddleware, guard=guard, config=config) | |||||
| | `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` | | ||||||
| | `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `60` | | ||||||
|
|
||||||
| ### Middleware Observability (Auto-Events) | ||||||
|
|
||||||
| Enable automatic event emission from the middleware to get visibility into request patterns, verification outcomes, and latency — no manual instrumentation required. | ||||||
|
|
||||||
| ```python | ||||||
| from capiscio_sdk.events import EventEmitter | ||||||
| from capiscio_sdk.integrations.fastapi import CapiscioMiddleware | ||||||
|
|
||||||
| emitter = EventEmitter(agent_id="...", api_key="...", registry_url="...") | ||||||
|
||||||
| emitter = EventEmitter(agent_id="...", api_key="...", registry_url="...") | |
| emitter = EventEmitter(agent_id="...", api_key="...", server_url="...") |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -58,6 +58,12 @@ class EventEmitter: | |||||||
| EVENT_ERROR = "error" | ||||||||
| EVENT_WARNING = "warning" | ||||||||
| EVENT_INFO = "info" | ||||||||
|
|
||||||||
| # Middleware auto-event types (emitted automatically by CapiscioMiddleware) | ||||||||
| EVENT_REQUEST_RECEIVED = "request.received" | ||||||||
| EVENT_REQUEST_COMPLETED = "request.completed" | ||||||||
|
||||||||
| EVENT_REQUEST_COMPLETED = "request.completed" | |
| EVENT_REQUEST_COMPLETED = "request.completed" | |
| EVENT_REQUEST_FAILED = "request.failed" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
|
|
||
| from ..simple_guard import SimpleGuard | ||
| from ..errors import VerificationError | ||
| from ..events import EventEmitter | ||
| import time | ||
| import logging | ||
|
|
||
|
|
@@ -27,6 +28,10 @@ class CapiscioMiddleware(BaseHTTPMiddleware): | |
| guard: SimpleGuard instance for verification. | ||
| exclude_paths: List of paths to skip verification (e.g., ["/health", "/.well-known/agent-card.json"]). | ||
| config: Optional SecurityConfig to control enforcement behavior. | ||
| emitter: Optional EventEmitter for auto-event emission. When provided, | ||
| the middleware automatically emits request.received, verification.success, | ||
| and verification.failed events. request.completed is emitted for requests | ||
| that reach the downstream ASGI app; blocked 4xx responses do not emit it. | ||
|
|
||
| Security behavior: | ||
| - If config is None, defaults to strict blocking mode | ||
|
|
@@ -40,18 +45,20 @@ def __init__( | |
| guard: SimpleGuard, | ||
| exclude_paths: Optional[List[str]] = None, | ||
| *, # Force config to be keyword-only | ||
| config: Optional["SecurityConfig"] = None | ||
| config: Optional["SecurityConfig"] = None, | ||
| emitter: Optional[EventEmitter] = None, | ||
| ) -> None: | ||
| super().__init__(app) | ||
| self.guard = guard | ||
| self.config = config | ||
| self.exclude_paths = exclude_paths or [] | ||
| self._emitter = emitter | ||
|
|
||
| # Default to strict mode if no config | ||
| self.require_signatures = config.downstream.require_signatures if config is not None else True | ||
| self.fail_mode = config.fail_mode if config is not None else "block" | ||
|
|
||
| logger.info(f"CapiscioMiddleware initialized: exclude_paths={self.exclude_paths}, require_signatures={self.require_signatures}, fail_mode={self.fail_mode}") | ||
| logger.info(f"CapiscioMiddleware initialized: exclude_paths={self.exclude_paths}, require_signatures={self.require_signatures}, fail_mode={self.fail_mode}, auto_events={emitter is not None}") | ||
|
|
||
| async def dispatch( | ||
| self, | ||
|
|
@@ -69,6 +76,14 @@ async def dispatch( | |
| logger.debug(f"CapiscioMiddleware: SKIPPING verification for {path}") | ||
| return await call_next(request) | ||
|
|
||
| request_start = time.perf_counter() | ||
|
|
||
| # Auto-event: request.received | ||
| self._auto_emit(EventEmitter.EVENT_REQUEST_RECEIVED, { | ||
| "method": request.method, | ||
| "path": path, | ||
| }) | ||
|
|
||
| # RFC-002 §9.1: X-Capiscio-Badge header | ||
| auth_header = request.headers.get("X-Capiscio-Badge") | ||
|
|
||
|
|
@@ -78,21 +93,37 @@ async def dispatch( | |
| # No badge required - allow through but mark as unverified | ||
| request.state.agent = None | ||
| request.state.agent_id = None | ||
| return await call_next(request) | ||
| response = await call_next(request) | ||
| self._auto_emit_completed(request, response, request_start) | ||
| return response | ||
|
|
||
| # Badge required but missing | ||
| if self.fail_mode in ("log", "monitor"): | ||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} ({self.fail_mode} mode)") | ||
| self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, { | ||
| "method": request.method, | ||
| "path": path, | ||
| "reason": "missing_badge", | ||
| "duration_ms": round((time.perf_counter() - request_start) * 1000, 2), | ||
| }) | ||
| request.state.agent = None | ||
| request.state.agent_id = None | ||
| return await call_next(request) | ||
| response = await call_next(request) | ||
|
Comment on lines
101
to
+111
|
||
| self._auto_emit_completed(request, response, request_start) | ||
| return response | ||
| else: # block | ||
| self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, { | ||
| "method": request.method, | ||
| "path": path, | ||
| "reason": "missing_badge", | ||
| "duration_ms": round((time.perf_counter() - request_start) * 1000, 2), | ||
| }) | ||
|
Comment on lines
+115
to
+120
|
||
| return JSONResponse( | ||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | ||
| status_code=401 | ||
| ) | ||
|
|
||
| start_time = time.perf_counter() | ||
| verify_start = time.perf_counter() | ||
| try: | ||
| # Read the body for integrity check | ||
| body_bytes = await request.body() | ||
|
|
@@ -108,8 +139,28 @@ async def receive() -> Dict[str, Any]: | |
| # Inject claims into request.state | ||
| request.state.agent = payload | ||
| request.state.agent_id = payload.get("iss") | ||
|
|
||
| verification_duration = (time.perf_counter() - verify_start) * 1000 | ||
|
|
||
| # Auto-event: verification.success | ||
| self._auto_emit(EventEmitter.EVENT_VERIFICATION_SUCCESS, { | ||
| "method": request.method, | ||
| "path": path, | ||
| "caller_did": payload.get("iss"), | ||
| "duration_ms": round(verification_duration, 2), | ||
| }) | ||
|
|
||
| except VerificationError as e: | ||
| verification_duration = (time.perf_counter() - verify_start) * 1000 | ||
|
|
||
| # Auto-event: verification.failed | ||
| self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, { | ||
| "method": request.method, | ||
| "path": path, | ||
| "reason": str(e), | ||
| "duration_ms": round(verification_duration, 2), | ||
| }) | ||
|
|
||
| if self.fail_mode in ("log", "monitor"): | ||
| logger.warning(f"Badge verification failed: {e} ({self.fail_mode} mode)") | ||
| request.state.agent = None | ||
|
|
@@ -118,16 +169,41 @@ async def receive() -> Dict[str, Any]: | |
| async def receive() -> Dict[str, Any]: | ||
| return {"type": "http.request", "body": body_bytes, "more_body": False} | ||
| request._receive = receive | ||
| return await call_next(request) | ||
| response = await call_next(request) | ||
| self._auto_emit_completed(request, response, request_start) | ||
| return response | ||
| else: # block | ||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | ||
|
|
||
| verification_duration = (time.perf_counter() - start_time) * 1000 | ||
|
|
||
| response = await call_next(request) | ||
|
|
||
| # Add Server-Timing header (standard for performance metrics) | ||
| # Syntax: metric_name;dur=123.4;desc="Description" | ||
| response.headers["Server-Timing"] = f"capiscio-auth;dur={verification_duration:.3f};desc=\"CapiscIO Verification\"" | ||
|
|
||
| # Auto-event: request.completed | ||
| self._auto_emit_completed(request, response, request_start) | ||
|
|
||
| return response | ||
|
|
||
| def _auto_emit(self, event_type: str, data: Dict[str, Any]) -> None: | ||
| """Emit an auto-event if emitter is configured.""" | ||
| if self._emitter: | ||
| try: | ||
| self._emitter.emit(event_type, data) | ||
| except Exception: | ||
| logger.debug(f"Failed to emit auto-event {event_type}", exc_info=True) | ||
|
Comment on lines
+189
to
+195
|
||
|
|
||
| def _auto_emit_completed( | ||
| self, request: Request, response: Response, start_time: float | ||
| ) -> None: | ||
| """Emit request.completed auto-event.""" | ||
| if self._emitter: | ||
| duration_ms = (time.perf_counter() - start_time) * 1000 | ||
| self._auto_emit(EventEmitter.EVENT_REQUEST_COMPLETED, { | ||
| "method": request.method, | ||
| "path": request.url.path, | ||
| "status_code": response.status_code, | ||
| "duration_ms": round(duration_ms, 2), | ||
| "caller_did": getattr(request.state, "agent_id", None), | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -201,6 +201,24 @@ This section provides detailed API documentation for all public modules in the C | |||||||||||||||||||||
| options: | ||||||||||||||||||||||
| show_root_heading: false | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## Events | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ::: capiscio_sdk.events | ||||||||||||||||||||||
| options: | ||||||||||||||||||||||
| members: | ||||||||||||||||||||||
| - EventEmitter | ||||||||||||||||||||||
| - EVENT_TASK_STARTED | ||||||||||||||||||||||
| - EVENT_TASK_COMPLETED | ||||||||||||||||||||||
| - EVENT_TASK_FAILED | ||||||||||||||||||||||
| - EVENT_TOOL_CALL | ||||||||||||||||||||||
| - EVENT_TOOL_RESULT | ||||||||||||||||||||||
| - EVENT_REQUEST_RECEIVED | ||||||||||||||||||||||
| - EVENT_REQUEST_COMPLETED | ||||||||||||||||||||||
| - EVENT_REQUEST_FAILED | ||||||||||||||||||||||
| - EVENT_VERIFICATION_SUCCESS | ||||||||||||||||||||||
| - EVENT_VERIFICATION_FAILED | ||||||||||||||||||||||
|
Comment on lines
+210
to
+219
|
||||||||||||||||||||||
| - EVENT_TASK_STARTED | |
| - EVENT_TASK_COMPLETED | |
| - EVENT_TASK_FAILED | |
| - EVENT_TOOL_CALL | |
| - EVENT_TOOL_RESULT | |
| - EVENT_REQUEST_RECEIVED | |
| - EVENT_REQUEST_COMPLETED | |
| - EVENT_REQUEST_FAILED | |
| - EVENT_VERIFICATION_SUCCESS | |
| - EVENT_VERIFICATION_FAILED |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -654,6 +654,72 @@ data: | |||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Middleware Observability (Auto-Events) | ||||||
|
|
||||||
| The `CapiscioMiddleware` can automatically emit events at key request lifecycle points, giving you visibility into traffic patterns, verification outcomes, and latency without manual instrumentation. | ||||||
|
|
||||||
| ### Enabling Auto-Events | ||||||
|
|
||||||
| Pass an `EventEmitter` instance to the middleware: | ||||||
|
|
||||||
| ```python | ||||||
| from capiscio_sdk.events import EventEmitter | ||||||
| from capiscio_sdk.integrations.fastapi import CapiscioMiddleware | ||||||
|
|
||||||
| emitter = EventEmitter( | ||||||
| agent_id="your-agent-id", | ||||||
| api_key="sk_live_...", | ||||||
| registry_url="https://registry.capisc.io" | ||||||
|
||||||
| registry_url="https://registry.capisc.io" | |
| server_url="https://registry.capisc.io" |
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section says CapiscIO.connect() returns an object with agent.emitter, but AgentIdentity currently only exposes emit(...) and keeps the emitter as a private _emitter field. Either add a public emitter property/attribute on AgentIdentity, or update the docs to show the supported pattern (e.g., construct an EventEmitter from agent.server_url/agent.api_key/agent.agent_id, or just call agent.emit(...)).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changelog lists
EVENT_REQUEST_FAILEDas a new constant, but it isn’t defined incapiscio_sdk.events(and no corresponding middleware event type appears to be emitted). Please either add/emit this event type or remove it from the changelog entry to keep release notes accurate.