Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
7 changes: 7 additions & 0 deletions capiscio_sdk/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EVENT_REQUEST_FAILED is introduced but not referenced anywhere in the repository. If it’s not part of the current auto-event contract, consider removing it to avoid confusing API consumers, or wire it into the middleware and add coverage.

Suggested change
EVENT_REQUEST_FAILED = "request.failed"

Copilot uses AI. Check for mistakes.
EVENT_VERIFICATION_SUCCESS = "verification.success"
EVENT_VERIFICATION_FAILED = "verification.failed"

def __init__(
self,
Expand Down
84 changes: 76 additions & 8 deletions capiscio_sdk/integrations/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ..simple_guard import SimpleGuard
from ..errors import VerificationError
from ..events import EventEmitter
import time
import logging

Expand All @@ -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.
Comment on lines +32 to +33
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring says the middleware "automatically emits request.completed" when an emitter is provided, but blocked requests (401/403 paths) return before _auto_emit_completed is called. Update the docstring to clarify when request.completed is (and is not) emitted, or emit it for blocked responses too.

Suggested change
the middleware automatically emits request.received, request.completed,
verification.success, and verification.failed events.
the middleware automatically emits request.received, verification.success,
and verification.failed events, and emits request.completed for requests
that reach the downstream ASGI app (blocked 4xx responses may not emit
request.completed).

Copilot uses AI. Check for mistakes.

Security behavior:
- If config is None, defaults to strict blocking mode
Expand All @@ -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,
Expand All @@ -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")

Expand All @@ -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)
Comment on lines 100 to +104
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the missing-badge branch for fail_mode = "log"/"monitor", the middleware currently emits request.received and request.completed but does not emit verification.failed. This conflicts with the PR description that verification.failed should be emitted when the badge is missing; consider emitting it here as well (without blocking).

Copilot uses AI. Check for mistakes.
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",
})
Comment on lines +108 to +112
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verification.failed auto-event emitted for a blocked missing-badge request does not include duration_ms, but the PR description lists duration_ms as a key field for verification failures. Consider including a duration (e.g., from request_start).

Copilot uses AI. Check for mistakes.
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()
Expand All @@ -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
Expand All @@ -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),
})
18 changes: 18 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading