This repository was archived by the owner on Oct 27, 2026. It is now read-only.
Repository navigation
Add API key usage tracking (SEL-346) #840
Closed
Closed
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
e9a616e
Add API key usage tracking (SEL-346)
cursoragent 79846e8
Fix FastAPI dependency injection for Request parameter
cursoragent d3ab8a6
fix: use ContextVar for AuthContext to avoid breaking get_auth_contex…
cursoragent 36103b8
fix: prefix unused request parameter with underscore to satisfy lint
cursoragent 97ac2b7
fix: add table alias for SQLite view subqueries
cursoragent 03bd398
fix: remove unused AuthContext import from middleware
cursoragent e4284e7
fix: remove _request parameter to avoid FastAPI response model infere…
cursoragent 71d54a7
fix: remove unused imports flagged by ruff
cursoragent b046d75
fix: format SQL strings to satisfy line-length requirements
cursoragent ec32c47
fix: apply ruff format to modified files
cursoragent 8b4e982
fix: re-add AsyncSession imports for mypy type inference
cursoragent 355720a
fix: add noqa comments to AsyncSession imports for ruff
cursoragent cc4eaf1
fix: use correct session_factory() pattern (single call, not double)
cursoragent 2089018
fix: use single backticks in migration docstring
cursoragent de827cd
chore: regenerate OpenAPI schema with new usage endpoint
cursoragent 9f0f490
Fix test suite failures for migration 051
cursoragent 591d2a7
Fix SQLite view creation in tests
cursoragent 850443c
Fix ruff format: wrap long assertion
cursoragent f6c9a3f
chore: trigger CI
cursoragent f3678d8
fix: handle SQLite string dates in usage endpoint
cursoragent db0ab96
fix: apply ruff format to api_keys.py
cursoragent da24e36
Merge main into api-key-usage-tracking
cursoragent 1d91094
Merge remote-tracking branch 'origin/main' into cursor/api-key-usage-…
cursoragent 32f0b9f
chore: trigger CI rerun
cursoragent a482687
Merge remote-tracking branch 'origin/main' into cursor/api-key-usage-…
cursoragent ae29519
Merge remote-tracking branch 'origin/main' into cursor/api-key-usage-…
cursoragent a318b93
chore: trigger final CI check
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -822,3 +822,4 @@ jobs: | |
|
|
||
| - name: Check doc references resolve | ||
| run: python scripts/check_doc_references.py | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| """ | ||
| API Key Usage Tracking Middleware | ||
|
|
||
| Records API key usage events after each authenticated request completes. | ||
| Captures method, route template, status code, and latency without blocking | ||
| the response path. A failed write never fails the request. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import logging | ||
| import time | ||
| from typing import Callable | ||
|
|
||
| from fastapi import Request, Response | ||
| from starlette.middleware.base import BaseHTTPMiddleware | ||
|
|
||
| from ..core.auth import CREDENTIAL_ACCEPTANCE, CURRENT_AUTH_CONTEXT | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class APIKeyUsageTrackingMiddleware(BaseHTTPMiddleware): | ||
| """ | ||
| Records API key usage events after authenticated requests complete. | ||
|
|
||
| This middleware runs after authentication resolves an API key and captures: | ||
| - key_id: The authenticated API key | ||
| - tenant: The tenant label from the key | ||
| - method: HTTP method | ||
| - route_template: The matched route template (not the raw path) | ||
| - status_code: Response status code | ||
| - latency_ms: Request duration in milliseconds | ||
|
|
||
| A failed write is logged and never blocks the response. | ||
| """ | ||
|
|
||
| async def dispatch(self, request: Request, call_next: Callable) -> Response: | ||
| start_time = time.time() | ||
|
|
||
| # Store start time in request state so we can track it after auth | ||
| request.state.usage_tracking_start = start_time | ||
|
|
||
| # Process the request (this will call auth and handlers) | ||
| response = await call_next(request) | ||
|
|
||
| # Only record for authenticated requests | ||
| acceptance = CREDENTIAL_ACCEPTANCE.get() | ||
| if acceptance is None or not acceptance.accepted: | ||
| return response | ||
|
|
||
| # Extract AuthContext from ContextVar (set by get_auth_context) | ||
| auth_context = CURRENT_AUTH_CONTEXT.get() | ||
| if auth_context is None: | ||
| # Not authenticated or auth context not available | ||
| return response | ||
|
|
||
| if auth_context.key_id is None: | ||
| # Bootstrap admin or static dev key - no usage tracking | ||
| return response | ||
|
|
||
| # Calculate latency | ||
| latency_ms = int((time.time() - start_time) * 1000) | ||
|
|
||
| # Determine route template | ||
| route = request.scope.get("route") | ||
| if route and hasattr(route, "path"): | ||
| route_template = route.path | ||
| else: | ||
| # Fallback to raw path if route template unavailable | ||
| route_template = request.url.path | ||
|
|
||
| # Record usage event asynchronously (fire and forget) | ||
| asyncio.create_task( | ||
| self._record_usage_event( | ||
| key_id=auth_context.key_id, | ||
| tenant=auth_context.tenant, | ||
| method=request.method, | ||
| route_template=route_template, | ||
| status_code=response.status_code, | ||
| latency_ms=latency_ms, | ||
| ) | ||
| ) | ||
|
|
||
| return response | ||
|
|
||
| async def _record_usage_event( | ||
| self, | ||
| key_id: str, | ||
| tenant: str | None, | ||
| method: str, | ||
| route_template: str, | ||
| status_code: int, | ||
| latency_ms: int, | ||
| ) -> None: | ||
| """ | ||
| Record the usage event. This runs asynchronously and never blocks. | ||
| """ | ||
| try: | ||
| from ..services.api_key_usage import record_api_key_usage_event | ||
|
|
||
| await record_api_key_usage_event( | ||
| key_id=key_id, | ||
| tenant=tenant, | ||
| method=method, | ||
| route_template=route_template, | ||
| status_code=status_code, | ||
| latency_ms=latency_ms, | ||
| ) | ||
| except Exception as e: | ||
| # Catch-all: even if the import or call fails, don't block | ||
| logger.error( | ||
| f"Failed to record API key usage event for {key_id}: {e}", | ||
| exc_info=True, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| """ | ||
| API Key Usage Event Tracking | ||
|
|
||
| Records per-call usage events for API keys after successful authentication. | ||
| Events are written asynchronously without blocking the request path. | ||
| A failed write never fails the request. | ||
| """ | ||
|
|
||
| import logging | ||
| from typing import Optional | ||
| from uuid import uuid4 | ||
|
|
||
| from sqlalchemy.ext.asyncio import AsyncSession # noqa: F401 | ||
|
|
||
| from ..core.time import to_naive_utc, utc_now | ||
| from ..db.database import get_session_factory | ||
| from ..db.models import APIKeyUsageEventModel | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def record_api_key_usage_event( | ||
| key_id: str, | ||
| tenant: Optional[str], | ||
| method: str, | ||
| route_template: str, | ||
| status_code: int, | ||
| latency_ms: int, | ||
| ) -> None: | ||
| """ | ||
| Record one API key usage event asynchronously. | ||
|
|
||
| Args: | ||
| key_id: The API key ID that authenticated | ||
| tenant: The tenant label (None = normal, "demo" = demo tenant) | ||
| method: HTTP method (GET, POST, etc.) | ||
| route_template: The route template (not the raw path with IDs) | ||
| status_code: HTTP response status code | ||
| latency_ms: Request latency in milliseconds | ||
|
|
||
| This function never raises. A failed write is logged and the request | ||
| continues unchanged. No request bodies, IPs, headers, or PII are stored. | ||
| """ | ||
| try: | ||
| event_id = f"ue_{uuid4().hex[:16]}" | ||
| now = to_naive_utc(utc_now()) | ||
|
|
||
| event = APIKeyUsageEventModel( | ||
| id=event_id, | ||
| key_id=key_id, | ||
| tenant=tenant, | ||
| method=method[:10], # Truncate to column size | ||
| route_template=route_template[:255], # Truncate to column size | ||
| status_code=status_code, | ||
| latency_ms=latency_ms, | ||
| created_at=now, | ||
| ) | ||
|
|
||
| session_factory = get_session_factory() | ||
| async with session_factory() as session: | ||
| session.add(event) | ||
| await session.commit() | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| logger.debug( | ||
| f"Recorded usage event {event_id} for key {key_id}: " | ||
| f"{method} {route_template} -> {status_code} ({latency_ms}ms)" | ||
| ) | ||
| except Exception as e: | ||
| # A failed write never fails the request. Log and continue. | ||
| logger.error( | ||
| f"Failed to record API key usage event for key {key_id}: {e}", | ||
| exc_info=True, | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.