|
| 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 |
0 commit comments