feat: add image route usage telemetry
This commit is contained in:
@@ -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]
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user