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
103 changes: 65 additions & 38 deletions backend/src/modules/api_keys/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
from typing import Any

from fastcrud.types import GetMultiResponseDict
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from ...infrastructure.logging import get_logger
from ..common.exceptions import PermissionDeniedError, ResourceNotFoundError
from .crud import crud_api_keys, crud_key_permissions, crud_key_usage
from .enums import KeyPermissionAction, KeyPermissionResource
from .models import APIKey
from .models import APIKey, KeyUsage
from .schemas import (
APIKeyCreate,
APIKeyCreateInternal,
Expand All @@ -26,14 +26,6 @@
KeyUsageCreate,
KeyUsageRead,
)
from .utils import (
calculate_basic_metrics,
calculate_daily_usage,
calculate_endpoint_usage,
calculate_error_breakdown,
calculate_response_time_metrics,
parse_usage_records,
)

logger = get_logger()

Expand Down Expand Up @@ -458,31 +450,62 @@ async def get_usage_analytics(

since_date = datetime.now(UTC) - timedelta(days=days)

result = await crud_key_usage.get_multi(
db=db,
api_key_id=key_id,
created_at__gte=since_date,
schema_to_select=KeyUsageRead,
# Aggregated in SQL rather than fetched and looped in Python: get_multi caps at
# FastCRUD's default page size (100 rows), which silently truncated every metric.
in_window = (KeyUsage.api_key_id == key_id, KeyUsage.created_at >= since_date)
successful = func.count().filter(KeyUsage.status_code.between(200, 299))
tokens = func.coalesce(func.sum(KeyUsage.tokens_used), 0)
cost = func.coalesce(func.sum(KeyUsage.cost_microcents), 0)

totals = (
await db.execute(
select(func.count(), successful, tokens, cost, func.avg(KeyUsage.response_time_ms)).where(*in_window)
)
).one()
total_requests, successful_requests, total_tokens, total_cost, avg_response_time = totals

endpoint_count = func.count().label("count")
endpoint_rows = await db.execute(
select(KeyUsage.endpoint, endpoint_count)
.where(*in_window)
.group_by(KeyUsage.endpoint)
.order_by(endpoint_count.desc(), KeyUsage.endpoint)
.limit(10)
)

usage_records = parse_usage_records(result)
basic_metrics = calculate_basic_metrics(usage_records)
avg_response_time = calculate_response_time_metrics(usage_records)
most_used_endpoints = calculate_endpoint_usage(usage_records)
error_breakdown = calculate_error_breakdown(usage_records)
usage_by_day = calculate_daily_usage(usage_records)
error_rows = await db.execute(
select(KeyUsage.status_code, func.count())
.where(*in_window, KeyUsage.status_code >= 400)
.group_by(KeyUsage.status_code)
.order_by(KeyUsage.status_code)
)

day = func.date(func.timezone("UTC", KeyUsage.created_at)).label("day")
daily_rows = await db.execute(
select(day, func.count(), successful, tokens, cost).where(*in_window).group_by(day).order_by(day)
)

return {
"api_key_id": key_id,
"total_requests": basic_metrics["total_requests"],
"successful_requests": basic_metrics["successful_requests"],
"failed_requests": basic_metrics["failed_requests"],
"total_tokens": basic_metrics["total_tokens"],
"total_cost_microcents": basic_metrics["total_cost"],
"average_response_time_ms": avg_response_time,
"most_used_endpoints": most_used_endpoints,
"error_breakdown": error_breakdown,
"usage_by_day": usage_by_day,
"total_requests": total_requests,
"successful_requests": successful_requests,
"failed_requests": total_requests - successful_requests,
"total_tokens": int(total_tokens),
"total_cost_microcents": int(total_cost),
"average_response_time_ms": float(avg_response_time) if avg_response_time is not None else None,
"most_used_endpoints": [{"endpoint": endpoint, "count": count} for endpoint, count in endpoint_rows],
"error_breakdown": {str(status_code): count for status_code, count in error_rows},
"usage_by_day": [
{
"date": row_day.isoformat(),
"requests": requests,
"successful_requests": day_successful,
"failed_requests": requests - day_successful,
"tokens": int(day_tokens),
"cost_microcents": int(day_cost),
}
for row_day, requests, day_successful, day_tokens, day_cost in daily_rows
],
}

async def get_user_summary(
Expand All @@ -505,14 +528,7 @@ async def get_user_summary(
total_requests_result = await crud_key_usage.count(db=db, user_id=user_id)
total_requests = total_requests_result if isinstance(total_requests_result, int) else 0

usage_result = await crud_key_usage.get_multi(db=db, user_id=user_id, schema_to_select=KeyUsageRead)
total_cost = 0
if isinstance(usage_result, dict) and usage_result.get("data"):
usage_data = usage_result["data"]
if isinstance(usage_data, list):
for u in usage_data:
if isinstance(u, dict) and u.get("cost_microcents"):
total_cost += u["cost_microcents"]
total_cost = await self.sum_user_usage_cost(user_id=user_id, db=db)

return {
"user_id": user_id,
Expand All @@ -523,6 +539,17 @@ async def get_user_summary(
"keys": keys_data,
}

async def sum_user_usage_cost(self, user_id: int, db: AsyncSession) -> int:
"""Return total ``cost_microcents`` across all of a user's key-usage records.

Replaces a fetch-all + Python-loop sum that silently capped at FastCRUD's
default page size (100 rows), under-reporting cost for any active user.
Single query, single round-trip. coalesce(..., 0) covers the no-usage
case (SUM returns NULL otherwise).
"""
stmt = select(func.coalesce(func.sum(KeyUsage.cost_microcents), 0)).where(KeyUsage.user_id == user_id)
return int((await db.execute(stmt)).scalar_one() or 0)

async def _check_permission(
self,
api_key_id: int,
Expand Down
149 changes: 0 additions & 149 deletions backend/src/modules/api_keys/utils.py

This file was deleted.

Loading
Loading