diff --git a/backend-services/doorman.py b/backend-services/doorman.py
index 2a52f53..04d1502 100755
--- a/backend-services/doorman.py
+++ b/backend-services/doorman.py
@@ -117,6 +117,28 @@ async def app_lifespan(app: FastAPI):
app.state._purger_task = asyncio.create_task(automatic_purger(1800))
+ # Restore persisted metrics (if available)
+ METRICS_FILE = os.path.join(LOGS_DIR, 'metrics.json')
+ try:
+ metrics_store.load_from_file(METRICS_FILE)
+ except Exception as e:
+ gateway_logger.debug(f'Metrics restore skipped: {e}')
+
+ # Start periodic metrics saver
+ async def _metrics_autosave(interval_s: int = 60):
+ while True:
+ try:
+ await asyncio.sleep(interval_s)
+ metrics_store.save_to_file(METRICS_FILE)
+ except asyncio.CancelledError:
+ break
+ except Exception:
+ pass
+ try:
+ app.state._metrics_save_task = asyncio.create_task(_metrics_autosave(60))
+ except Exception:
+ app.state._metrics_save_task = None
+
try:
await load_settings()
await start_auto_save_task()
@@ -213,6 +235,20 @@ async def app_lifespan(app: FastAPI):
except Exception:
pass
+ # Persist metrics on shutdown
+ try:
+ METRICS_FILE = os.path.join(LOGS_DIR, 'metrics.json')
+ metrics_store.save_to_file(METRICS_FILE)
+ except Exception:
+ pass
+ # Stop autosave task
+ try:
+ t = getattr(app.state, '_metrics_save_task', None)
+ if t:
+ t.cancel()
+ except Exception:
+ pass
+
def _generate_unique_id(route):
try:
name = getattr(route, 'name', 'op') or 'op'
@@ -614,7 +650,7 @@ async def metrics_middleware(request: Request, call_next):
if username:
from utils.bandwidth_util import add_usage, _get_user
u = _get_user(username)
- # Track usage only if not explicitly disabled and a limit is configured
+ # Track usage when limit is set unless explicitly disabled
if u and u.get('bandwidth_limit_bytes') and u.get('bandwidth_limit_enabled') is not False:
add_usage(username, int(bytes_in) + int(clen), u.get('bandwidth_limit_window') or 'day')
except Exception:
diff --git a/backend-services/utils/bandwidth_util.py b/backend-services/utils/bandwidth_util.py
index 7463fda..f039e5a 100644
--- a/backend-services/utils/bandwidth_util.py
+++ b/backend-services/utils/bandwidth_util.py
@@ -81,8 +81,7 @@ async def enforce_pre_request_limit(request: Request, username: Optional[str]) -
user = _get_user(username)
if not user:
return
- # Only enforce if explicitly enabled or not disabled.
- # Backwards compatibility: if field is absent (None), treat as enabled when limit > 0
+ # Enforce when limit is set unless explicitly disabled
if user.get('bandwidth_limit_enabled') is False:
return
limit = user.get('bandwidth_limit_bytes')
diff --git a/backend-services/utils/limit_throttle_util.py b/backend-services/utils/limit_throttle_util.py
index 4da1a83..7224549 100644
--- a/backend-services/utils/limit_throttle_util.py
+++ b/backend-services/utils/limit_throttle_util.py
@@ -59,10 +59,12 @@ async def limit_and_throttle(request: Request):
if not user:
user = user_collection.find_one({'username': username})
now_ms = int(time.time() * 1000)
- # Rate limiting (skip if explicitly disabled)
- if user.get('rate_limit_enabled') is not False:
- rate = int(user.get('rate_limit_duration') or 1)
- duration = user.get('rate_limit_duration_type', 'minute')
+ # Rate limiting (enabled if explicitly set true, or legacy values exist)
+ rate_enabled = (user.get('rate_limit_enabled') is True) or bool(user.get('rate_limit_duration'))
+ if rate_enabled:
+ # Use user-set values; if explicitly enabled but missing values, fall back to sensible defaults
+ rate = int(user.get('rate_limit_duration') or 60)
+ duration = user.get('rate_limit_duration_type') or 'minute'
window = duration_to_seconds(duration)
key = f'rate_limit:{username}:{now_ms // (window * 1000)}'
try:
@@ -77,10 +79,11 @@ async def limit_and_throttle(request: Request):
if count > rate:
raise HTTPException(status_code=429, detail='Rate limit exceeded')
- # Throttling (skip if explicitly disabled)
- if user.get('throttle_enabled') is not False:
- throttle_limit = int(user.get('throttle_duration') or 5)
- throttle_duration = user.get('throttle_duration_type', 'second')
+ # Throttling (enabled if explicitly set true, or legacy values exist)
+ throttle_enabled = (user.get('throttle_enabled') is True) or bool(user.get('throttle_duration'))
+ if throttle_enabled:
+ throttle_limit = int(user.get('throttle_duration') or 10)
+ throttle_duration = user.get('throttle_duration_type') or 'second'
throttle_window = duration_to_seconds(throttle_duration)
throttle_key = f'throttle_limit:{username}:{now_ms // (throttle_window * 1000)}'
try:
diff --git a/backend-services/utils/metrics_util.py b/backend-services/utils/metrics_util.py
index 22570eb..5be942b 100644
--- a/backend-services/utils/metrics_util.py
+++ b/backend-services/utils/metrics_util.py
@@ -9,6 +9,8 @@ import time
from collections import defaultdict, deque
from dataclasses import dataclass, field
from typing import Deque, Dict, List, Optional
+import json
+import os
@dataclass
class MinuteBucket:
@@ -48,6 +50,39 @@ class MinuteBucket:
except Exception:
pass
+ def to_dict(self) -> Dict:
+ return {
+ 'start_ts': self.start_ts,
+ 'count': self.count,
+ 'error_count': self.error_count,
+ 'total_ms': self.total_ms,
+ 'bytes_in': self.bytes_in,
+ 'bytes_out': self.bytes_out,
+ 'status_counts': dict(self.status_counts or {}),
+ 'api_counts': dict(self.api_counts or {}),
+ 'api_error_counts': dict(self.api_error_counts or {}),
+ 'user_counts': dict(self.user_counts or {}),
+ }
+
+ @staticmethod
+ def from_dict(d: Dict) -> 'MinuteBucket':
+ mb = MinuteBucket(
+ start_ts=int(d.get('start_ts', 0)),
+ count=int(d.get('count', 0)),
+ error_count=int(d.get('error_count', 0)),
+ total_ms=float(d.get('total_ms', 0.0)),
+ bytes_in=int(d.get('bytes_in', 0)),
+ bytes_out=int(d.get('bytes_out', 0)),
+ )
+ try:
+ mb.status_counts = dict(d.get('status_counts') or {})
+ mb.api_counts = dict(d.get('api_counts') or {})
+ mb.api_error_counts = dict(d.get('api_error_counts') or {})
+ mb.user_counts = dict(d.get('user_counts') or {})
+ except Exception:
+ pass
+ return mb
+
if username:
try:
self.user_counts[username] = self.user_counts.get(username, 0) + 1
@@ -170,5 +205,60 @@ class MetricsStore:
'top_apis': sorted(self.api_counts.items(), key=lambda kv: kv[1], reverse=True)[:10],
}
+ def to_dict(self) -> Dict:
+ return {
+ 'total_requests': int(self.total_requests),
+ 'total_ms': float(self.total_ms),
+ 'total_bytes_in': int(self.total_bytes_in),
+ 'total_bytes_out': int(self.total_bytes_out),
+ 'status_counts': dict(self.status_counts),
+ 'username_counts': dict(self.username_counts),
+ 'api_counts': dict(self.api_counts),
+ 'buckets': [b.to_dict() for b in list(self._buckets)],
+ }
+
+ def load_dict(self, data: Dict) -> None:
+ try:
+ self.total_requests = int(data.get('total_requests', 0))
+ self.total_ms = float(data.get('total_ms', 0.0))
+ self.total_bytes_in = int(data.get('total_bytes_in', 0))
+ self.total_bytes_out = int(data.get('total_bytes_out', 0))
+ self.status_counts = defaultdict(int, data.get('status_counts') or {})
+ self.username_counts = defaultdict(int, data.get('username_counts') or {})
+ self.api_counts = defaultdict(int, data.get('api_counts') or {})
+ self._buckets.clear()
+ for bd in data.get('buckets', []):
+ try:
+ self._buckets.append(MinuteBucket.from_dict(bd))
+ except Exception:
+ continue
+ except Exception:
+ # If anything goes wrong, keep current in-memory metrics
+ pass
+
+ def save_to_file(self, path: str) -> None:
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ except Exception:
+ pass
+ try:
+ tmp = path + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(self.to_dict(), f)
+ os.replace(tmp, path)
+ except Exception:
+ pass
+
+ def load_from_file(self, path: str) -> None:
+ try:
+ if not os.path.exists(path):
+ return
+ with open(path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ if isinstance(data, dict):
+ self.load_dict(data)
+ except Exception:
+ pass
+
# Global metrics store
metrics_store = MetricsStore()
diff --git a/web-client/src/app/logging/page.tsx b/web-client/src/app/logging/page.tsx
index fb2f786..75fcdba 100644
--- a/web-client/src/app/logging/page.tsx
+++ b/web-client/src/app/logging/page.tsx
@@ -68,9 +68,7 @@ export default function LogsPage() {
const [error, setError] = useState
No custom attributes
)}