From 7c726433cdad4e9e81ad155d662958b43820cbe4 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Tue, 24 Feb 2026 14:26:23 -0500 Subject: [PATCH 1/2] feat: add auto-event emission to CapiscioMiddleware Add opt-in automatic event emission to the FastAPI/Starlette middleware. When an EventEmitter is provided, the middleware automatically emits: - request.received: on every inbound request - verification.success/failed: after badge verification - request.completed: after response with status_code and duration_ms Design: - Fully opt-in: no emitter = no events (backward compatible) - Safe: emitter errors never break request handling - Standardized fields: method, path, caller_did, duration_ms, status_code --- capiscio_sdk/events.py | 7 + capiscio_sdk/integrations/fastapi.py | 84 ++++++++++- tests/unit/test_fastapi_integration.py | 197 ++++++++++++++++++++++++- 3 files changed, 279 insertions(+), 9 deletions(-) diff --git a/capiscio_sdk/events.py b/capiscio_sdk/events.py index ec296f2..a5717fe 100644 --- a/capiscio_sdk/events.py +++ b/capiscio_sdk/events.py @@ -58,6 +58,13 @@ 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_FAILED = "request.failed" + EVENT_VERIFICATION_SUCCESS = "verification.success" + EVENT_VERIFICATION_FAILED = "verification.failed" def __init__( self, diff --git a/capiscio_sdk/integrations/fastapi.py b/capiscio_sdk/integrations/fastapi.py index 8cd4c26..8b2e3aa 100644 --- a/capiscio_sdk/integrations/fastapi.py +++ b/capiscio_sdk/integrations/fastapi.py @@ -10,6 +10,7 @@ from ..simple_guard import SimpleGuard from ..errors import VerificationError +from ..events import EventEmitter import time import logging @@ -27,6 +28,9 @@ 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, request.completed, + verification.success, and verification.failed events. Security behavior: - If config is None, defaults to strict blocking mode @@ -40,18 +44,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 +75,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 +92,30 @@ 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)") 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 else: # block + self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, { + "method": request.method, + "path": path, + "reason": "missing_badge", + }) 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 +131,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 +161,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) + + 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), + }) diff --git a/tests/unit/test_fastapi_integration.py b/tests/unit/test_fastapi_integration.py index 10b0adf..d842dfa 100644 --- a/tests/unit/test_fastapi_integration.py +++ b/tests/unit/test_fastapi_integration.py @@ -5,10 +5,11 @@ """ import json import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, call from fastapi import FastAPI, Request from fastapi.testclient import TestClient from capiscio_sdk.errors import VerificationError +from capiscio_sdk.events import EventEmitter from capiscio_sdk.integrations.fastapi import CapiscioMiddleware from capiscio_sdk.config import SecurityConfig, DownstreamConfig @@ -425,3 +426,197 @@ async def test_endpoint(request: Request): data = response.json() assert data["agent"] is None assert data["agent_id"] is None + + +class TestAutoEvents: + """Tests for middleware auto-event emission.""" + + def _make_app(self, mock_guard, emitter, **middleware_kwargs): + app = FastAPI() + app.add_middleware( + CapiscioMiddleware, + guard=mock_guard, + emitter=emitter, + exclude_paths=["/.well-known/agent.json"], + **middleware_kwargs, + ) + + @app.post("/tasks/send") + async def send_task(request: Request): + return {"agent_id": getattr(request.state, "agent_id", None)} + + @app.get("/.well-known/agent.json") + async def agent_card(): + return {"name": "test"} + + return app + + def test_auto_events_on_successful_request(self): + """Emitter receives request.received, verification.success, request.completed.""" + guard = MagicMock() + guard.agent_id = "test-agent" + guard.verify_inbound.return_value = {"iss": "did:key:caller123", "sub": "recipient"} + + emitter = MagicMock(spec=EventEmitter) + + app = self._make_app(guard, emitter) + client = TestClient(app) + + body = json.dumps({"message": "hello"}).encode() + response = client.post( + "/tasks/send", + content=body, + headers={"X-Capiscio-Badge": "valid.jws.token", "Content-Type": "application/json"}, + ) + assert response.status_code == 200 + + event_types = [c.args[0] for c in emitter.emit.call_args_list] + assert "request.received" in event_types + assert "verification.success" in event_types + assert "request.completed" in event_types + + # Verify request.received data + received_call = next(c for c in emitter.emit.call_args_list if c.args[0] == "request.received") + assert received_call.args[1]["method"] == "POST" + assert received_call.args[1]["path"] == "/tasks/send" + + # Verify verification.success data + success_call = next(c for c in emitter.emit.call_args_list if c.args[0] == "verification.success") + assert success_call.args[1]["caller_did"] == "did:key:caller123" + assert "duration_ms" in success_call.args[1] + + # Verify request.completed data + completed_call = next(c for c in emitter.emit.call_args_list if c.args[0] == "request.completed") + assert completed_call.args[1]["status_code"] == 200 + assert "duration_ms" in completed_call.args[1] + + def test_auto_events_on_missing_badge_block(self): + """Emitter receives request.received and verification.failed when badge missing.""" + guard = MagicMock() + guard.agent_id = "test-agent" + + emitter = MagicMock(spec=EventEmitter) + + app = self._make_app(guard, emitter) + client = TestClient(app) + + response = client.post("/tasks/send", json={"msg": "hi"}) + assert response.status_code == 401 + + event_types = [c.args[0] for c in emitter.emit.call_args_list] + assert "request.received" in event_types + assert "verification.failed" in event_types + # No request.completed on blocked requests + assert "request.completed" not in event_types + + failed_call = next(c for c in emitter.emit.call_args_list if c.args[0] == "verification.failed") + assert failed_call.args[1]["reason"] == "missing_badge" + + def test_auto_events_on_verification_failure_block(self): + """Emitter receives verification.failed when badge is invalid.""" + guard = MagicMock() + guard.agent_id = "test-agent" + guard.verify_inbound.side_effect = VerificationError("Bad signature") + + emitter = MagicMock(spec=EventEmitter) + + app = self._make_app(guard, emitter) + client = TestClient(app) + + response = client.post( + "/tasks/send", + json={"msg": "hi"}, + headers={"X-Capiscio-Badge": "bad.jws.token"}, + ) + assert response.status_code == 403 + + event_types = [c.args[0] for c in emitter.emit.call_args_list] + assert "request.received" in event_types + assert "verification.failed" in event_types + + failed_call = next(c for c in emitter.emit.call_args_list if c.args[0] == "verification.failed") + assert failed_call.args[1]["reason"] == "Bad signature" + assert "duration_ms" in failed_call.args[1] + + def test_auto_events_on_verification_failure_log_mode(self): + """In log mode, verification.failed + request.completed both emitted.""" + guard = MagicMock() + guard.agent_id = "test-agent" + guard.verify_inbound.side_effect = VerificationError("Expired badge") + + emitter = MagicMock(spec=EventEmitter) + + config = SecurityConfig( + downstream=DownstreamConfig(require_signatures=True), + fail_mode="log", + ) + app = self._make_app(guard, emitter, config=config) + client = TestClient(app) + + response = client.post( + "/tasks/send", + json={"msg": "hi"}, + headers={"X-Capiscio-Badge": "expired.jws.token"}, + ) + assert response.status_code == 200 + + event_types = [c.args[0] for c in emitter.emit.call_args_list] + assert "request.received" in event_types + assert "verification.failed" in event_types + assert "request.completed" in event_types + + def test_no_auto_events_without_emitter(self): + """Without emitter, middleware works normally (backward compat).""" + guard = MagicMock() + guard.agent_id = "test-agent" + guard.verify_inbound.return_value = {"iss": "did:key:abc", "sub": "test"} + + app = FastAPI() + app.add_middleware(CapiscioMiddleware, guard=guard) + + @app.post("/test") + async def test_endpoint(request: Request): + return {"ok": True} + + client = TestClient(app) + response = client.post( + "/test", + json={}, + headers={"X-Capiscio-Badge": "valid.token"}, + ) + assert response.status_code == 200 + + def test_no_auto_events_for_excluded_paths(self): + """Excluded paths don't trigger any auto-events.""" + guard = MagicMock() + guard.agent_id = "test-agent" + + emitter = MagicMock(spec=EventEmitter) + + app = self._make_app(guard, emitter) + client = TestClient(app) + + response = client.get("/.well-known/agent.json") + assert response.status_code == 200 + + emitter.emit.assert_not_called() + + def test_auto_event_emitter_error_does_not_break_request(self): + """If emitter throws, the request still succeeds.""" + guard = MagicMock() + guard.agent_id = "test-agent" + guard.verify_inbound.return_value = {"iss": "did:key:abc", "sub": "test"} + + emitter = MagicMock(spec=EventEmitter) + emitter.emit.side_effect = Exception("Network error") + + app = self._make_app(guard, emitter) + client = TestClient(app) + + response = client.post( + "/tasks/send", + json={"msg": "hi"}, + headers={"X-Capiscio-Badge": "valid.token"}, + ) + # Request should still succeed even if emitter fails + assert response.status_code == 200 From 85edf113baf82ea8d2fb21e32b81d7c84dbdea51 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Tue, 24 Feb 2026 14:34:35 -0500 Subject: [PATCH 2/2] docs: add auto-events documentation - CHANGELOG.md: document middleware auto-events feature - README.md: add middleware observability section - docs/guides/configuration.md: add auto-events configuration guide - docs/api-reference.md: add EventEmitter and event constants --- CHANGELOG.md | 7 ++++ README.md | 24 +++++++++++++ docs/api-reference.md | 18 ++++++++++ docs/guides/configuration.md | 66 ++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb5fbd..4b55334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Binary caching in `~/.capiscio/bin/` directory - Automatic executable permissions for Unix-like systems - Fallback search order: `CAPISCIO_BINARY` env var → local development path → system PATH → cached binary → auto-download +- **Middleware Auto-Events**: `CapiscioMiddleware` now supports automatic event emission + - Opt-in via `emitter` parameter — pass an `EventEmitter` to enable + - Emits `request.received`, `verification.success`/`verification.failed`, and `request.completed` events + - Standardized fields: `method`, `path`, `caller_did`, `duration_ms`, `status_code` + - Safe by design: emitter errors never break request handling + - Excluded paths emit no events + - New event type constants: `EVENT_REQUEST_RECEIVED`, `EVENT_REQUEST_COMPLETED`, `EVENT_REQUEST_FAILED`, `EVENT_VERIFICATION_SUCCESS`, `EVENT_VERIFICATION_FAILED` ### Changed - **Improved Process Management**: Enhanced error logging and binary discovery diff --git a/README.md b/README.md index e230639..0ea701d 100644 --- a/README.md +++ b/README.md @@ -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="...") +app.add_middleware(CapiscioMiddleware, guard=guard, emitter=emitter) +# Events flow automatically — no other code changes needed +``` + +The middleware emits these events when an `emitter` is provided: + +| Event | When | Key Fields | +|-------|------|------------| +| `request.received` | Every inbound request | `method`, `path` | +| `verification.success` | Badge verified | `method`, `path`, `caller_did`, `duration_ms` | +| `verification.failed` | Badge missing/invalid | `method`, `path`, `reason`, `duration_ms` | +| `request.completed` | Response sent | `method`, `path`, `status_code`, `duration_ms`, `caller_did` | + +**Privacy note:** Auto-events are strictly opt-in. No telemetry is sent unless you explicitly pass an `emitter`. Excluded paths emit no events. + ## 🛡️ What You Get (Out of the Box) 1. **Zero-Config Identity**: diff --git a/docs/api-reference.md b/docs/api-reference.md index 13e6160..c922136 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 + show_root_heading: false + ## Errors ::: capiscio_sdk.errors diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index e3a41ef..079c9fc 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -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" +) + +app.add_middleware( + CapiscioMiddleware, + guard=guard, + emitter=emitter, # Enables auto-events + exclude_paths=["/health"] +) +``` + +### Emitted Events + +| Event Type | When Emitted | Fields | +|------------|-------------|--------| +| `request.received` | Every inbound request (after exclusion check) | `method`, `path` | +| `verification.success` | Badge verified successfully | `method`, `path`, `caller_did`, `duration_ms` | +| `verification.failed` | Badge missing or verification failed | `method`, `path`, `reason`, `duration_ms` | +| `request.completed` | After response is sent | `method`, `path`, `status_code`, `duration_ms`, `caller_did` | + +### Privacy & Opt-In Design + +Auto-events are **strictly opt-in**. Without an `emitter` parameter, the middleware emits nothing — identical behavior to previous versions. + +This is intentional: event data includes request paths and caller identities, which may be sensitive. The developer must explicitly enable telemetry by constructing and passing an `EventEmitter`. + +Paths listed in `exclude_paths` (e.g., `/health`) emit no events at all. + +### Error Resilience + +Event emission is wrapped in error handling — if the emitter encounters a network error or other failure, the request proceeds normally. Observability never degrades availability. + +### Using with CapiscIO.connect() + +If you're using `CapiscIO.connect()`, the agent identity already has an emitter: + +```python +from capiscio_sdk import CapiscIO +from capiscio_sdk.integrations.fastapi import CapiscioMiddleware + +agent = CapiscIO.connect(api_key="sk_live_...") + +app.add_middleware( + CapiscioMiddleware, + guard=guard, + emitter=agent.emitter # Use the agent's emitter +) +``` + +--- + ## Common Scenarios ### API Gateway