Skip to content

Commit 7d9211c

Browse files
committed
feat: record request metrics
1 parent 2d4e456 commit 7d9211c

4 files changed

Lines changed: 166 additions & 2 deletions

File tree

routes/core.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,20 @@ def openrouter_dashboard():
461461
return jsonify({"status": "error", "message": str(error)}), 500
462462
return render_template("500.html", error=str(error)), 500
463463

464+
@app.route("/admin/metrics/requests")
465+
@login_required
466+
def admin_request_metrics():
467+
require_admin_dashboard_user()
468+
try:
469+
limit = max(1, min(int(request.args.get("limit", 100)), 500))
470+
except ValueError:
471+
limit = 100
472+
return jsonify(
473+
{
474+
"requests": MetricsService.get_instance().get_request_records(limit=limit),
475+
}
476+
)
477+
464478
@app.route("/dashboard/openrouter/chat-completions", methods=["POST"])
465479
@login_required
466480
def dashboard_openrouter_chat_completions():

services/metrics_service.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from collections import deque
33
from threading import Lock
44
from datetime import datetime, timedelta
5+
from typing import Optional
6+
7+
from flask import g, has_request_context, request
58

69
class MetricsService:
710
_instance = None
@@ -79,14 +82,62 @@ def _build_traffic_series(self, recent_requests, hours=24, now=None):
7982

8083
return buckets
8184

82-
def track_request(self, provider, status_code, response_time, timestamp=None):
85+
def _request_context_metadata(self, provider):
86+
if not has_request_context():
87+
return {}
88+
89+
user = getattr(g, "authenticated_user", None) or {}
90+
rate_limit = getattr(g, "rate_limit", None) or {}
91+
payload = request.get_json(silent=True) if request.is_json else None
92+
model = payload.get("model") if isinstance(payload, dict) else None
93+
if model and ":" not in str(model) and provider:
94+
model = f"{provider}:{model}"
95+
96+
return {
97+
"request_id": getattr(g, "request_id", None),
98+
"user_id": user.get("username") or user.get("id"),
99+
"api_key_prefix": user.get("api_key_prefix"),
100+
"model": model,
101+
"input_tokens": rate_limit.get("input_tokens"),
102+
"output_tokens": rate_limit.get("output_tokens"),
103+
"estimated_tokens": rate_limit.get("estimated_tokens"),
104+
}
105+
106+
def track_request(
107+
self,
108+
provider,
109+
status_code,
110+
response_time,
111+
timestamp=None,
112+
request_id: Optional[str] = None,
113+
user_id: Optional[str] = None,
114+
api_key_prefix: Optional[str] = None,
115+
model: Optional[str] = None,
116+
input_tokens: Optional[int] = None,
117+
output_tokens: Optional[int] = None,
118+
estimated_tokens: Optional[int] = None,
119+
estimated_cost: Optional[float] = None,
120+
actual_cost: Optional[float] = None,
121+
ttft_ms: Optional[float] = None,
122+
):
83123
"""Track a new request"""
84124
now = timestamp if timestamp is not None else time.time()
125+
context_metadata = self._request_context_metadata(provider)
85126
self.requests.append({
86127
'timestamp': now,
87128
'provider': provider,
88129
'status_code': status_code,
89-
'response_time': response_time
130+
'response_time': response_time,
131+
'request_id': request_id or context_metadata.get("request_id"),
132+
'user_id': user_id or context_metadata.get("user_id"),
133+
'api_key_prefix': api_key_prefix or context_metadata.get("api_key_prefix"),
134+
'model': model or context_metadata.get("model"),
135+
'input_tokens': input_tokens if input_tokens is not None else context_metadata.get("input_tokens"),
136+
'output_tokens': output_tokens if output_tokens is not None else context_metadata.get("output_tokens"),
137+
'estimated_tokens': estimated_tokens if estimated_tokens is not None else context_metadata.get("estimated_tokens"),
138+
'estimated_cost': estimated_cost,
139+
'actual_cost': actual_cost,
140+
'ttft_ms': ttft_ms,
90141
})
91142

92143
def get_stats(self, hours=24, now=None):
@@ -248,13 +299,44 @@ def get_recent_failures(self, limit=5, now=None, hours=24):
248299
return [
249300
{
250301
"time": datetime.fromtimestamp(request["timestamp"]).strftime('%Y-%m-%d %H:%M:%S'),
302+
"request_id": request.get("request_id"),
251303
"provider": request["provider"],
304+
"model": request.get("model"),
252305
"status_code": request["status_code"],
253306
"response_time": round(request["response_time"], 2),
254307
"status_bucket": self._status_code_bucket(request["status_code"]),
255308
}
256309
for request in failures
257310
]
311+
312+
def get_request_records(self, limit=100, now=None, hours=24):
313+
"""Return recent request-level records without prompt or response bodies."""
314+
current_time = now if now is not None else time.time()
315+
records = sorted(
316+
self._get_recent_requests(hours=hours, now=current_time),
317+
key=lambda item: item["timestamp"],
318+
reverse=True,
319+
)[:limit]
320+
321+
return [
322+
{
323+
"time": datetime.fromtimestamp(record["timestamp"]).strftime('%Y-%m-%d %H:%M:%S'),
324+
"request_id": record.get("request_id"),
325+
"user_id": record.get("user_id"),
326+
"api_key_prefix": record.get("api_key_prefix"),
327+
"provider": record["provider"],
328+
"model": record.get("model"),
329+
"status_code": record["status_code"],
330+
"input_tokens": record.get("input_tokens"),
331+
"output_tokens": record.get("output_tokens"),
332+
"estimated_tokens": record.get("estimated_tokens"),
333+
"estimated_cost": record.get("estimated_cost"),
334+
"actual_cost": record.get("actual_cost"),
335+
"response_time": round(record["response_time"], 2),
336+
"ttft_ms": record.get("ttft_ms"),
337+
}
338+
for record in records
339+
]
258340

259341
def get_recent_activity(self, limit=10):
260342
"""Get recent activity for the status page"""

test_metrics_service.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import unittest
22

3+
from flask import Flask, g
4+
35
from services.metrics_service import MetricsService
46

57

@@ -49,6 +51,39 @@ def test_get_recent_failures_returns_newest_errors_first(self):
4951
self.assertEqual(failures[1]["provider"], "openrouter")
5052
self.assertEqual(failures[1]["status_code"], 429)
5153

54+
def test_track_request_records_request_metadata_without_prompt_text(self):
55+
app = Flask(__name__)
56+
metrics = MetricsService()
57+
58+
with app.test_request_context(
59+
"/v1/chat/completions",
60+
method="POST",
61+
json={
62+
"model": "opencode:kimi-k2.5",
63+
"messages": [{"role": "user", "content": "secret prompt"}],
64+
},
65+
):
66+
g.request_id = "req_test"
67+
g.authenticated_user = {
68+
"username": "alice",
69+
"api_key_prefix": "mllm_live_alice",
70+
}
71+
g.rate_limit = {
72+
"input_tokens": 12,
73+
"output_tokens": 6,
74+
"estimated_tokens": 18,
75+
}
76+
metrics.track_request("opencode", 200, 42)
77+
78+
record = metrics.get_request_records(limit=1)[0]
79+
self.assertEqual(record["request_id"], "req_test")
80+
self.assertEqual(record["user_id"], "alice")
81+
self.assertEqual(record["api_key_prefix"], "mllm_live_alice")
82+
self.assertEqual(record["model"], "opencode:kimi-k2.5")
83+
self.assertEqual(record["input_tokens"], 12)
84+
self.assertNotIn("prompt", record)
85+
self.assertNotIn("secret prompt", str(record))
86+
5287

5388
if __name__ == "__main__":
5489
unittest.main()

test_opencode_provider.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,39 @@ def test_status_json_includes_dashboard_analytics(self):
164164
self.assertIn("traffic_series", payload["stats"])
165165
self.assertEqual(payload["stats"]["failed_requests"], 1)
166166

167+
def test_admin_request_metrics_returns_request_records(self):
168+
metrics_service = self.app_module.MetricsService.get_instance()
169+
metrics_service.requests.clear()
170+
metrics_service.track_request(
171+
"opencode",
172+
200,
173+
45,
174+
request_id="req-admin-test",
175+
user_id="admin",
176+
api_key_prefix="mllm_live_admin",
177+
model="opencode:kimi-k2.5",
178+
input_tokens=4,
179+
output_tokens=8,
180+
)
181+
182+
with self.client.session_transaction() as session:
183+
session["authenticated"] = True
184+
session["user"] = {
185+
"username": "admin",
186+
"is_admin": True,
187+
"api_key_prefix": "mllm_live_admin",
188+
"scopes": ["admin"],
189+
}
190+
191+
response = self.client.get("/admin/metrics/requests?limit=1")
192+
193+
self.assertEqual(response.status_code, 200)
194+
record = response.get_json()["requests"][0]
195+
self.assertEqual(record["request_id"], "req-admin-test")
196+
self.assertEqual(record["model"], "opencode:kimi-k2.5")
197+
self.assertEqual(record["input_tokens"], 4)
198+
self.assertNotIn("prompt", record)
199+
167200
def test_streaming_proxy_response_is_returned_without_rewrapping(self):
168201
upstream_stream = Response(
169202
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n',

0 commit comments

Comments
 (0)