Skip to content

Commit 7660621

Browse files
authored
Rate Monitor: Per-org request rate alerting on high-cost endpoints (#911)
1 parent 51efb34 commit 7660621

7 files changed

Lines changed: 353 additions & 4 deletions

File tree

‎backend/app/api/routes/collections.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.api.deps import SessionDep, AuthContextDep
99
from app.api.permissions import Permission, require_permission
1010
from app.core.telemetry import log_context
11+
from app.core.rate_monitor import monitor_rate
1112
from app.crud import (
1213
CollectionCrud,
1314
CollectionJobCrud,
@@ -85,7 +86,10 @@ def list_collections(
8586
description=load_description("collections/create.md"),
8687
response_model=APIResponse[CollectionJobImmediatePublic],
8788
callbacks=collection_callback_router.routes,
88-
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
89+
dependencies=[
90+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
91+
Depends(monitor_rate("collections")),
92+
],
8993
)
9094
def create_collection(
9195
session: SessionDep,

‎backend/app/api/routes/evaluations/evaluation.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313

1414
from app.api.deps import AuthContextDep, SessionDep
15+
from app.core.rate_monitor import monitor_rate
1516
from app.crud.evaluations import list_evaluation_runs as list_evaluation_runs_crud
1617
from app.crud.evaluations.core import group_traces_by_question_id
1718
from app.models.evaluation import EvaluationRunPublic
@@ -34,7 +35,10 @@
3435
"",
3536
description=load_description("evaluation/create_evaluation.md"),
3637
response_model=APIResponse[EvaluationRunPublic],
37-
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
38+
dependencies=[
39+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
40+
Depends(monitor_rate("evaluations")),
41+
],
3842
)
3943
def evaluate(
4044
session: SessionDep,

‎backend/app/api/routes/llm.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from app.api.permissions import Permission, require_permission
1010
from app.core.cloud.storage import get_cloud_storage
1111
from app.core.telemetry import log_context
12+
from app.core.rate_monitor import monitor_rate
1213
from app.crud.jobs import JobCrud
1314
from app.crud.llm import get_llm_calls_by_job_id
1415
from app.models import (
@@ -22,7 +23,6 @@
2223
from app.services.llm.jobs import start_job
2324
from app.utils import APIResponse, validate_callback_url, load_description
2425

25-
2626
logger = logging.getLogger(__name__)
2727

2828
router = APIRouter(tags=["LLM"])
@@ -50,7 +50,10 @@ def llm_callback_notification(body: APIResponse[LLMCallResponse]):
5050
description=load_description("llm/llm_call.md"),
5151
response_model=APIResponse[LLMJobImmediatePublic],
5252
callbacks=llm_callback_router.routes,
53-
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
53+
dependencies=[
54+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
55+
Depends(monitor_rate("llm_call")),
56+
],
5457
)
5558
def llm_call(
5659
_current_user: AuthContextDep, session: SessionDep, request: LLMCallRequest

‎backend/app/core/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ def AWS_S3_BUCKET(self) -> str:
151151
BACKEND_SERVICE_NAME: str = "kaapi-backend"
152152
CRON_SERVICE_NAME: str = "kaapi-cron"
153153

154+
# Threshold Request Rate per minute
155+
THRESHOLD_LLM_CALL_RATE: int = 15
156+
THRESHOLD_COLLECTIONS_RATE: int = 3
157+
THRESHOLD_EVALUATIONS_RATE: int = 3
158+
154159
# Celery Configuration
155160
CELERY_WORKER_CONCURRENCY: int | None = None
156161
CELERY_WORKER_MAX_TASKS_PER_CHILD: int = 150

‎backend/app/core/rate_monitor.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import logging
2+
import time
3+
4+
from collections.abc import Callable
5+
from typing import Literal
6+
7+
import redis
8+
9+
from app.api.deps import AuthContextDep
10+
from app.core.config import settings
11+
12+
from app.core.telemetry import record_rate_threshold
13+
14+
logger = logging.getLogger(__name__)
15+
16+
# Categories of rates we want to monitor
17+
RateCategory = Literal["llm_call", "collections", "evaluations"]
18+
19+
# THRESHOLD NUMBERS
20+
THRESHOLDS: dict[RateCategory, int] = {
21+
"llm_call": settings.THRESHOLD_LLM_CALL_RATE,
22+
"collections": settings.THRESHOLD_COLLECTIONS_RATE,
23+
"evaluations": settings.THRESHOLD_EVALUATIONS_RATE,
24+
}
25+
26+
# Delete record after 2 minutes from redis
27+
_EXPIRATION_SECONDS = 120
28+
29+
_redis_client: redis.Redis = redis.from_url(settings.REDIS_URL, decode_responses=True)
30+
31+
32+
# count incrementor after each request and get count
33+
def increment_and_get_count(key: str) -> int | None:
34+
"""Increment the count for the given key and return the new count.
35+
The count will automatically expire after _EXPIRATION_SECONDS.
36+
"""
37+
try:
38+
pipe = _redis_client.pipeline()
39+
pipe.incr(key)
40+
pipe.expire(key, _EXPIRATION_SECONDS)
41+
count, _ = pipe.execute()
42+
return count
43+
except Exception as e:
44+
logger.error(
45+
f"[increment_and_get_count] Error incrementing count for {key}: {e}"
46+
)
47+
return None
48+
49+
50+
def monitor_rate(category: RateCategory) -> Callable[[AuthContextDep], None]:
51+
"""Monitor the rate of events for the given category. If the rate exceeds the threshold, record it in telemetry.
52+
53+
Usage:
54+
dependencies=[
55+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
56+
Depends(monitor_rate("{category}")),
57+
]
58+
"""
59+
60+
def _checker(auth_context: AuthContextDep) -> None:
61+
project = auth_context.project
62+
if project is None:
63+
return
64+
65+
threshold = THRESHOLDS.get(category, None)
66+
if threshold is None:
67+
logger.warning(
68+
f"[monitor_rate] No threshold defined for category {category}"
69+
)
70+
return
71+
72+
minute_bucket = int(time.time() // 60)
73+
redis_key = f"rate_monitor:{category}:{project.id}:{minute_bucket}"
74+
75+
count = increment_and_get_count(redis_key)
76+
if count is not None and count == threshold + 1:
77+
logger.warning(
78+
f"[monitor_rate] Rate threshold exceeded for {category} in project {project.id}: count={count}"
79+
)
80+
record_rate_threshold(
81+
project_id=project.id,
82+
project_name=project.name,
83+
category=category,
84+
request_count=count,
85+
threshold=threshold,
86+
)
87+
88+
return _checker

‎backend/app/core/telemetry.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,34 @@ def record_stale_pending_jobs(
453453
)
454454

455455

456+
def record_rate_threshold(
457+
*,
458+
project_id: int,
459+
project_name: str | None,
460+
category: str,
461+
request_count: int,
462+
threshold: int,
463+
) -> None:
464+
"""Emit rate threshold exceeded event to Sentry."""
465+
466+
try:
467+
if not sentry_sdk.get_client().is_active():
468+
return
469+
with sentry_sdk.push_scope() as scope:
470+
scope.set_tag("alert.type", "threshold_rate_monitor")
471+
scope.set_tag("tenant.project_id", project_id)
472+
scope.set_tag("route_category", category)
473+
scope.set_extra("request_count", request_count)
474+
scope.set_extra("threshold", threshold)
475+
sentry_sdk.capture_message(
476+
f"[Threshold-Monitor] {category} rate limit exceeded for project {project_id} | {project_name}: {request_count} req/min "
477+
f"(limit {threshold}/min)",
478+
level="warning",
479+
)
480+
except Exception as e:
481+
logger.exception("[record_rate_threshold] Failed to emit alert", exc_info=e)
482+
483+
456484
def flush_telemetry(timeout_millis: int = 10000) -> None:
457485
"""Force-flush OTel spans into Sentry, then flush Sentry's transport.
458486

0 commit comments

Comments
 (0)