feat: add image route usage telemetry

This commit is contained in:
QiuSW
2026-07-08 22:44:17 +08:00
parent 25a4080177
commit d5656b4f56
13 changed files with 379 additions and 18 deletions
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import json
import logging
from time import perf_counter
from typing import Any, Mapping
logger = logging.getLogger("cmhub.api.generation_usage")
EVENT_NAME = "generation_route_usage"
MAX_CLIENT_VERSION_LENGTH = 64
MAX_ALIAS_LENGTH = 64
def telemetry_start_time() -> float:
return perf_counter()
def telemetry_elapsed_ms(started: float) -> int:
return max(0, int((perf_counter() - started) * 1000))
def request_alias(data: Mapping[str, Any]) -> str:
try:
return normalize_text(data.get("model", ""), MAX_ALIAS_LENGTH)
except AttributeError:
return ""
def log_generation_route_usage(
*,
route_type: str,
request,
alias: str,
status: str,
latency_ms: int,
error_code: str = "",
http_status: int | None = None,
) -> dict[str, Any]:
event = build_generation_route_usage_event(
route_type=route_type,
request=request,
alias=alias,
status=status,
latency_ms=latency_ms,
error_code=error_code,
http_status=http_status,
)
logger.info(
"%s %s",
EVENT_NAME,
json.dumps(event, ensure_ascii=False, sort_keys=True),
extra={"generation_route_usage": event},
)
return event
def build_generation_route_usage_event(
*,
route_type: str,
request,
alias: str,
status: str,
latency_ms: int,
error_code: str = "",
http_status: int | None = None,
) -> dict[str, Any]:
api_key = getattr(request, "auth", None)
user = getattr(request, "user", None)
return {
"event": EVENT_NAME,
"route_type": normalize_text(route_type, 16),
"api_key_id": getattr(api_key, "id", None),
"api_key_prefix": normalize_text(getattr(api_key, "key_prefix", ""), 32),
"user_id": getattr(user, "id", None),
"client_version": normalize_text(
request.headers.get("X-Client-Version", ""),
MAX_CLIENT_VERSION_LENGTH,
),
"alias": normalize_text(alias, MAX_ALIAS_LENGTH),
"status": normalize_text(status, 32),
"latency_ms": max(0, int(latency_ms)),
"error_code": normalize_text(error_code, 64),
"http_status": http_status,
}
def normalize_text(value: Any, max_length: int) -> str:
return str(value or "").strip()[:max_length]
+123
View File
@@ -1168,6 +1168,43 @@ class GenerateApiTests(TestCase):
with patch("apps.api.generation.get_provider", return_value=provider or self.provider):
return self.client.post(path, payload, format="json", **self.auth_header(), **extra)
def telemetry_event_from_logs(self, captured):
events = [
getattr(record, "generation_route_usage", None)
for record in captured.records
if getattr(record, "generation_route_usage", None)
]
self.assertEqual(len(events), 1)
return events[0]
def assert_generation_telemetry_is_safe(self, event, *, payload=None):
self.assertEqual(
set(event),
{
"event",
"route_type",
"api_key_id",
"api_key_prefix",
"user_id",
"client_version",
"alias",
"status",
"latency_ms",
"error_code",
"http_status",
},
)
serialized = json.dumps(event, ensure_ascii=False)
self.assertNotIn(self.raw_key, serialized)
self.assertNotIn("SECRET_RAW", serialized)
self.assertNotIn("prompt", serialized)
self.assertNotIn("image_base64", serialized)
if payload:
self.assertNotIn(str(payload.get("prompt") or ""), serialized)
encoded_image = str(payload.get("image_base64") or "")
if encoded_image:
self.assertNotIn(encoded_image, serialized)
def assert_generation_not_charged(self):
self.wallet.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
@@ -1288,6 +1325,92 @@ class GenerateApiTests(TestCase):
self.assertEqual(call.result_summary, "image_bytes=21")
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary)
def test_sync_image_usage_telemetry_logs_safe_client_version_and_key_identity(self):
encoded = base64.b64encode(b"input-image").decode("ascii")
payload = {
"prompt": "生成图片遥测测试",
"model": self.image_alias,
"image_base64": f"data:image/png;base64,{encoded}",
"resolution": "1K",
"aspect_ratio": "1:1",
}
with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured:
response = self.post_with_provider(
"/api/v1/generate/image",
payload,
HTTP_X_CLIENT_VERSION="0.1.1",
)
self.assertEqual(response.status_code, 200)
event = self.telemetry_event_from_logs(captured)
self.assertEqual(event["event"], "generation_route_usage")
self.assertEqual(event["route_type"], "sync")
self.assertEqual(event["api_key_id"], self.api_key.id)
self.assertEqual(event["api_key_prefix"], self.api_key.key_prefix)
self.assertEqual(event["user_id"], self.user.id)
self.assertEqual(event["client_version"], "0.1.1")
self.assertEqual(event["alias"], self.image_alias)
self.assertEqual(event["status"], "success")
self.assertEqual(event["error_code"], "")
self.assertEqual(event["http_status"], 200)
self.assertIsInstance(event["latency_ms"], int)
self.assertGreaterEqual(event["latency_ms"], 0)
self.assert_generation_telemetry_is_safe(event, payload=payload)
def test_async_image_submit_usage_telemetry_logs_safe_success_event(self):
payload = {
"prompt": "生成异步图片遥测测试",
"model": self.image_alias,
"resolution": "1K",
}
with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured:
response = self.post_with_provider(
"/api/v1/generate/image/tasks",
payload,
HTTP_X_CLIENT_VERSION="0.1.2",
)
self.assertEqual(response.status_code, 202)
event = self.telemetry_event_from_logs(captured)
self.assertEqual(event["route_type"], "async")
self.assertEqual(event["api_key_id"], self.api_key.id)
self.assertEqual(event["api_key_prefix"], self.api_key.key_prefix)
self.assertEqual(event["user_id"], self.user.id)
self.assertEqual(event["client_version"], "0.1.2")
self.assertEqual(event["alias"], self.image_alias)
self.assertEqual(event["status"], "success")
self.assertEqual(event["error_code"], "")
self.assertEqual(event["http_status"], 202)
self.assert_generation_telemetry_is_safe(event, payload=payload)
def test_async_image_submit_usage_telemetry_logs_error_code_without_sensitive_data(self):
self.wallet.points_balance = 1
self.wallet.save(update_fields=("points_balance", "updated_at"))
payload = {
"prompt": "余额不足遥测测试",
"model": self.image_alias,
"resolution": "1K",
}
with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured:
response = self.post_with_provider(
"/api/v1/generate/image/tasks",
payload,
HTTP_X_CLIENT_VERSION="0.1.3",
)
self.assertEqual(response.status_code, 402)
event = self.telemetry_event_from_logs(captured)
self.assertEqual(event["route_type"], "async")
self.assertEqual(event["status"], "error")
self.assertEqual(event["error_code"], "insufficient_points")
self.assertEqual(event["http_status"], 402)
self.assertEqual(event["client_version"], "0.1.3")
self.assertEqual(event["alias"], self.image_alias)
self.assert_generation_telemetry_is_safe(event, payload=payload)
@override_settings(
MODERATION_ENABLED=True,
MODERATION_PROVIDER="keyword",
+64
View File
@@ -30,6 +30,12 @@ from apps.api.serializers import (
RechargeCreateRequestSerializer,
RechargeStatusRequestSerializer,
)
from apps.api.telemetry import (
log_generation_route_usage,
request_alias,
telemetry_elapsed_ms,
telemetry_start_time,
)
from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure
from apps.ai.catalog import get_public_model_catalog
from apps.billing.models import RechargeOrder
@@ -95,12 +101,24 @@ class GenerateImageView(ExternalApiView):
throttle_classes = (GenerateRateThrottle,)
def post(self, request):
started = telemetry_start_time()
alias = request_alias(request.data)
serializer = GenerateImageRequestSerializer(data=request.data)
if not serializer.is_valid():
log_generation_route_usage(
route_type="sync",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code="bad_request",
http_status=status.HTTP_400_BAD_REQUEST,
)
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
alias = serializer.validated_data.get("model") or alias
try:
data = generate_image_response(
user=request.user,
@@ -109,7 +127,24 @@ class GenerateImageView(ExternalApiView):
image_url_builder=request.build_absolute_uri,
)
except ApiRequestError as exc:
log_generation_route_usage(
route_type="sync",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code=exc.code,
http_status=exc.http_status,
)
return Response(exc.as_response_data(), status=exc.http_status)
log_generation_route_usage(
route_type="sync",
request=request,
alias=data.get("alias") or alias,
status="success",
latency_ms=telemetry_elapsed_ms(started),
http_status=status.HTTP_200_OK,
)
return Response(data, status=status.HTTP_200_OK)
@@ -117,12 +152,24 @@ class GenerateImageTaskSubmitView(ExternalApiView):
throttle_classes = (GenerateRateThrottle,)
def post(self, request):
started = telemetry_start_time()
alias = request_alias(request.data)
serializer = GenerateImageRequestSerializer(data=request.data)
if not serializer.is_valid():
log_generation_route_usage(
route_type="async",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code="bad_request",
http_status=status.HTTP_400_BAD_REQUEST,
)
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
alias = serializer.validated_data.get("model") or alias
try:
task, _created = create_image_generation_task(
user=request.user,
@@ -131,7 +178,24 @@ class GenerateImageTaskSubmitView(ExternalApiView):
idempotency_key=request.headers.get("Idempotency-Key", ""),
)
except ApiRequestError as exc:
log_generation_route_usage(
route_type="async",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code=exc.code,
http_status=exc.http_status,
)
return Response(exc.as_response_data(), status=exc.http_status)
log_generation_route_usage(
route_type="async",
request=request,
alias=task.call_record.alias,
status="success",
latency_ms=telemetry_elapsed_ms(started),
http_status=status.HTTP_202_ACCEPTED,
)
return Response(task_submit_response(task), status=status.HTTP_202_ACCEPTED)