Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ bruno.json
.ruff_cache/
*.csv
*.png
METRICS_IMPLEMENTATION.md
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ The API provides multiple endpoints for authentication, documentation, and monit
| `/authenticate` | `POST` | Authenticates a user using their PESU credentials. |
| `/health` | `GET` | A health check endpoint to monitor the API's status. |
| `/readme` | `GET` | Redirects to the project's official GitHub repository. |
| `/metrics` | `GET` | Returns current application metrics and counters. |

### `/authenticate`

Expand Down Expand Up @@ -160,6 +161,31 @@ does not take any request parameters.

This endpoint redirects to the project's official GitHub repository. This endpoint does not take any request parameters.

### `/metrics`

This endpoint provides application metrics for monitoring authentication success rates, error counts, and system performance. It's useful for observability and debugging. This endpoint does not take any request parameters.

#### Response Object

| **Field** | **Type** | **Description** |
|-----------|------------|-------------------------------------------------------------------|
| `status` | `boolean` | `true` if metrics retrieved successfully, `false` if there was an error |
| `message` | `string` | Success message or error description |
| `timestamp` | `string` | A timezone offset timestamp indicating when metrics were retrieved |
| `metrics` | `object` | Dictionary containing all current metric counters |

The `metrics` object includes counters for:
- `auth_success_total` - Successful authentication attempts
- `auth_failure_total` - Failed authentication attempts
- `validation_error_total` - Request validation failures
- `pesu_academy_error_total` - PESU Academy service errors
- `unhandled_exception_total` - Unexpected application errors
- `csrf_token_error_total` - CSRF token extraction failures
- `profile_fetch_error_total` - Profile page fetch failures
- `profile_parse_error_total` - Profile parsing errors
- `csrf_token_refresh_success_total` - Successful background CSRF refreshes
- `csrf_token_refresh_failure_total` - Failed background CSRF refreshes

### Integrating your application with the PESUAuth API

Here are some examples of how you can integrate your application with the PESUAuth API using Python and cURL.
Expand Down
4 changes: 4 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""PESUAuth API - A simple API to authenticate PESU credentials using PESU Academy."""

from app.metrics import metrics

__all__ = ["metrics"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove this; we should not want to initialize a global collector outside the entry point.

54 changes: 52 additions & 2 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
from pydantic import ValidationError

from app.docs import authenticate_docs, health_docs, readme_docs
from app.docs.metrics import metrics_docs
from app.exceptions.base import PESUAcademyError
from app.metrics import metrics # Global metrics instance
from app.models import RequestModel, ResponseModel
from app.pesu import PESUAcademy

Expand All @@ -29,8 +31,13 @@ async def _refresh_csrf_token_with_lock() -> None:
"""Refresh the CSRF token with a lock."""
logging.debug("Refreshing unauthenticated CSRF token...")
async with CSRF_TOKEN_REFRESH_LOCK:
await pesu_academy.prefetch_client_with_csrf_token()
logging.info("Unauthenticated CSRF token refreshed successfully.")
try:
await pesu_academy.prefetch_client_with_csrf_token()
metrics.inc("csrf_token_refresh_success_total")
logging.info("Unauthenticated CSRF token refreshed successfully.")
except Exception:
metrics.inc("csrf_token_refresh_failure_total")
raise


async def _csrf_token_refresh_loop() -> None:
Expand All @@ -40,6 +47,7 @@ async def _csrf_token_refresh_loop() -> None:
logging.debug("Refreshing unauthenticated CSRF token...")
await _refresh_csrf_token_with_lock()
except Exception:
metrics.inc("csrf_token_refresh_failure_total")
logging.exception("Failed to refresh unauthenticated CSRF token in the background.")
await asyncio.sleep(CSRF_TOKEN_REFRESH_INTERVAL_SECONDS)

Expand Down Expand Up @@ -100,6 +108,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Handler for request validation errors."""
metrics.inc("validation_error_total")
logging.exception("Request data could not be validated.")
errors = exc.errors()
message = "; ".join([f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in errors])
Expand All @@ -116,6 +125,19 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
@app.exception_handler(PESUAcademyError)
async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSONResponse:
"""Handler for PESUAcademy specific errors."""
metrics.inc("pesu_academy_error_total")

# Track specific error types
exc_type = type(exc).__name__.lower()
if "csrf" in exc_type:
metrics.inc("csrf_token_error_total")
elif "profilefetch" in exc_type:
metrics.inc("profile_fetch_error_total")
elif "profileparse" in exc_type:
metrics.inc("profile_parse_error_total")
elif "authentication" in exc_type:
metrics.inc("auth_failure_total")

Comment on lines +159 to +168

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There is a much cleaner solution. Look into a middleware layer. Here is some pseudo code to get you started:

@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
    metrics.inc("requests_total")
    start_time = time.time()

    try:
        response: Response = await call_next(request)
        latency = time.time() - start_time

        # Track successes vs failures
        if 200 <= response.status_code < 300:
            metrics.inc("requests_success")
        else:
            metrics.inc("requests_failed")
            metrics.inc(f"requests_failed_status_{response.status_code}")

        # Latency metrics
        metrics.inc("request_latency_sum", latency)

        # Also add route metrics: route = request.scope.get("route")

        return response

    except Exception as e:
        latency = time.time() - start_time
        metrics.inc("requests_failed")
        metrics.inc(f"requests_failed_exception_{type(e).__name__}")
        metrics.inc("request_latency_sum", latency)

        raise

Note, you will need to accordingly increment other metrics like how many with and without profile data by parsing the request.

logging.exception(f"PESUAcademyError: {exc.message}")
return JSONResponse(
status_code=exc.status_code,
Expand All @@ -130,6 +152,7 @@ async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSO
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handler for unhandled exceptions."""
metrics.inc("unhandled_exception_total")
logging.exception("Unhandled exception occurred.")
return JSONResponse(
status_code=500,
Expand Down Expand Up @@ -160,6 +183,27 @@ async def health() -> JSONResponse:
)


@app.get(
"/metrics",
response_class=JSONResponse,
responses=metrics_docs.response_examples,
tags=["Monitoring"],
)
async def get_metrics() -> JSONResponse:
"""Get current application metrics."""
logging.debug("Metrics requested.")
current_metrics = metrics.get()
return JSONResponse(
status_code=200,
content={
"status": True,
"message": "Metrics retrieved successfully",
"timestamp": datetime.datetime.now(IST).isoformat(),
"metrics": current_metrics,
},
)


@app.get(
"/readme",
response_class=RedirectResponse,
Expand Down Expand Up @@ -199,6 +243,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks)
# Authenticate the user
authentication_result = {"timestamp": current_time}
logging.info(f"Authenticating user={username} with PESU Academy...")

authentication_result.update(
await pesu_academy.authenticate(
username=username,
Expand All @@ -207,6 +252,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks)
fields=fields,
),
)

# Prefetch a new client with an unauthenticated CSRF token for the next request
background_tasks.add_task(_refresh_csrf_token_with_lock)

Expand All @@ -216,6 +262,10 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks)
logging.info(f"Returning auth result for user={username}: {authentication_result}")
authentication_result = authentication_result.model_dump(exclude_none=True)
authentication_result["timestamp"] = current_time.isoformat()

# Track successful authentication only after validation succeeds
metrics.inc("auth_success_total")

return JSONResponse(
status_code=200,
content=authentication_result,
Expand Down
41 changes: 41 additions & 0 deletions app/docs/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Custom docs for the /metrics PESUAuth endpoint."""

from app.docs.base import ApiDocs

metrics_docs = ApiDocs(
request_examples={}, # GET endpoint doesn't need request examples
response_examples={
200: {
"description": "Metrics retrieved successfully",
"content": {
"application/json": {
"examples": {
"metrics_response": {
"summary": "Current Metrics",
"description": (
"All current application metrics including authentication counts and error rates"
),
"value": {
"status": True,
"message": "Metrics retrieved successfully",
"timestamp": "2025-08-28T15:30:45.123456+05:30",
"metrics": {
"auth_success_total": 150,
"auth_failure_total": 12,
Comment on lines +23 to +24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should also track how many auth requests are received, including a split for how many with and without profile data

"validation_error_total": 8,
"pesu_academy_error_total": 5,
"unhandled_exception_total": 0,
"csrf_token_error_total": 2,
"profile_fetch_error_total": 1,
"profile_parse_error_total": 0,
"csrf_token_refresh_success_total": 45,
"csrf_token_refresh_failure_total": 1,
},
},
}
}
}
},
}
},
Comment on lines +8 to +40

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Create a model for this. The response model will also need an update.

)
35 changes: 35 additions & 0 deletions app/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Metrics collector for tracking authentication successes, failures, and error types."""

import threading
from collections import defaultdict


class MetricsCollector:
"""Thread-safe metrics collector for tracking application performance and usage."""

def __init__(self) -> None:
"""Initialize the metrics collector with thread safety."""
self.lock = threading.Lock()
self.metrics = defaultdict(int)

def inc(self, key: str) -> None:
"""Increment a metric counter by 1.

Args:
key (str): The metric key to increment.
"""
with self.lock:
self.metrics[key] += 1

def get(self) -> dict[str, int]:
"""Get a copy of all current metrics.

Returns:
dict[str, int]: Dictionary containing all metrics and their current values.
"""
with self.lock:
return dict(self.metrics)


# Global metrics instance
metrics = MetricsCollector()
10 changes: 10 additions & 0 deletions pyproject.toml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are these dependencies necessary to be added ?

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ dependencies = [
"pydantic>=2.6.0",
"pytz>=2025.2",
"selectolax>=0.3.30",
"ruff>=0.12.7",
"python-dotenv>=1.1.1",
"tqdm>=4.67.1",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -74,3 +77,10 @@ exclude = [
[tool.ruff.format]
quote-style = "double"
docstring-code-format = true

[dependency-groups]
dev = [
"pytest>=8.4.1",
"pytest-asyncio>=1.1.0",
"pytest-cov>=6.2.1",
]
21 changes: 20 additions & 1 deletion scripts/benchmark/util.py
Comment thread
snigenigmatic marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,36 @@ def make_request(
"password": os.getenv("TEST_PASSWORD"),
"profile": profile,
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "Benchmark-Test/1.0",
}
start_time = time.time()
response = client.post(
f"{host}/{route}",
json=data,
headers=headers,
follow_redirects=True,
)
else:
headers = {"Accept": "application/json", "User-Agent": "Benchmark-Test/1.0"}
start_time = time.time()
response = client.get(
f"{host}/{route}",
headers=headers,
follow_redirects=True,
)
elapsed_time = time.time() - start_time
return response.json(), elapsed_time

# Handle different response types
try:
return response.json(), elapsed_time
except ValueError:
# For non-JSON responses (like HTML redirects), return status info
return {
"status_code": response.status_code,
"content_type": response.headers.get("content-type", ""),
"content_length": len(response.content),
"response_time": elapsed_time,
}, elapsed_time
Loading