diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 9b2f29de7..36fb7ffaa 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -889,6 +889,8 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket( &state, &request_context, admin.session().subject.as_str(), + Some(admin.session().subject.as_str()), + None, CreateDirectUploadTicketRequest { legacy_prefix: "generated-character-drafts".to_string(), path_segments: vec![ @@ -944,6 +946,8 @@ pub async fn admin_confirm_editor_showcase_campaign_image_upload( &state, &request_context, admin.session().subject.as_str(), + Some(admin.session().subject.as_str()), + None, confirm_request, ) .await diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 7fcd33959..14ab3acde 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -237,12 +237,15 @@ async fn spacetime_unavailable_handler( async fn record_api_tracking_after_success( axum::extract::State(state): axum::extract::State, Extension(request_context): Extension, - request: Request, + mut request: Request, next: middleware::Next, ) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); let client_marker = resolve_tracking_client_marker(request.headers()); + if let Some(client_marker) = client_marker { + request.extensions_mut().insert(client_marker); + } let response = next.run(request).await; let authenticated = response .extensions() diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index cfd70d42e..bc79e7935 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -32,7 +32,7 @@ use crate::{ platform_errors::map_oss_error, request_context::RequestContext, state::AppState, - tracking::{TrackingEventDraft, record_tracking_event_after_success}, + tracking::{TrackingClientMarker, TrackingEventDraft, record_tracking_event_after_success}, }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 @@ -75,12 +75,15 @@ pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { create_direct_upload_ticket_for_owner( &state, &request_context, authenticated.claims().user_id(), + Some(authenticated.claims().user_id()), + client_marker.map(|Extension(marker)| marker), payload, ) .await @@ -90,6 +93,8 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( state: &AppState, request_context: &RequestContext, owner_user_id: &str, + tracking_user_id: Option<&str>, + client_marker: Option, payload: CreateDirectUploadTicketRequest, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { @@ -140,8 +145,10 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( record_asset_tracking_event( state, request_context, + tracking_user_id, owner_user_id, "asset_upload_ticket_create", + client_marker, json!({ "asset": { "operation": "asset_upload_ticket_create", @@ -454,12 +461,15 @@ pub async fn confirm_asset_object( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { confirm_asset_object_for_owner( &state, &request_context, authenticated.claims().user_id(), + Some(authenticated.claims().user_id()), + client_marker.map(|Extension(marker)| marker), payload, ) .await @@ -469,6 +479,8 @@ pub(crate) async fn confirm_asset_object_for_owner( state: &AppState, request_context: &RequestContext, owner_user_id: &str, + tracking_user_id: Option<&str>, + client_marker: Option, payload: ConfirmAssetObjectRequest, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { @@ -509,8 +521,10 @@ pub(crate) async fn confirm_asset_object_for_owner( record_asset_tracking_event( state, request_context, + tracking_user_id, owner_user_id, "asset_upload_confirm", + client_marker, json!({ "asset": { "operation": "asset_upload_confirm", @@ -542,6 +556,7 @@ pub async fn bind_asset_object_to_entity( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { let now_micros = current_utc_micros(); @@ -580,8 +595,10 @@ pub async fn bind_asset_object_to_entity( record_asset_tracking_event( &state, &request_context, + Some(authenticated.claims().user_id()), authenticated.claims().user_id(), "asset_bind", + client_marker.map(|Extension(marker)| marker), json!({ "asset": { "operation": "asset_bind", @@ -608,15 +625,84 @@ pub async fn bind_asset_object_to_entity( async fn record_asset_tracking_event( state: &AppState, request_context: &RequestContext, - user_id: &str, + tracking_user_id: Option<&str>, + owner_user_id: &str, event_key: &'static str, + client_marker: Option, metadata: Value, ) { - let mut draft = TrackingEventDraft::user(event_key, "asset", user_id); - draft.metadata = metadata; + let draft = build_asset_tracking_event_draft( + request_context, + tracking_user_id, + owner_user_id, + event_key, + client_marker, + metadata, + ); record_tracking_event_after_success(state, request_context, draft).await; } +fn build_asset_tracking_event_draft( + request_context: &RequestContext, + tracking_user_id: Option<&str>, + owner_user_id: &str, + event_key: &'static str, + client_marker: Option, + metadata: Value, +) -> TrackingEventDraft { + let mut draft = TrackingEventDraft::new(event_key, "asset"); + draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + draft.scope_id = owner_user_id.trim().to_string(); + draft.user_id = tracking_user_id.map(|user_id| user_id.trim().to_string()); + draft.owner_user_id = Some(owner_user_id.trim().to_string()); + draft.client_marker = client_marker; + let metadata = apply_asset_tracking_client_marker(metadata, client_marker); + draft.metadata = if client_marker.is_some() { + apply_asset_tracking_route_metadata(metadata, request_context) + } else { + metadata + }; + draft +} + +fn apply_asset_tracking_client_marker( + mut metadata: Value, + client_marker: Option, +) -> Value { + if matches!(client_marker, Some(TrackingClientMarker::Agc)) + && let Some(object) = metadata.as_object_mut() + { + object.insert("client".to_string(), json!("agc")); + } + metadata +} + +fn apply_asset_tracking_route_metadata( + mut metadata: Value, + request_context: &RequestContext, +) -> Value { + let Some(object) = metadata.as_object_mut() else { + return metadata; + }; + let Some((method, request_uri)) = request_context.operation().split_once(' ') else { + return metadata; + }; + let route = request_uri.split('?').next().unwrap_or(request_uri); + object + .entry("route".to_string()) + .or_insert_with(|| json!(route)); + object + .entry("method".to_string()) + .or_insert_with(|| json!(method)); + object + .entry("status".to_string()) + .or_insert_with(|| json!(200)); + object + .entry("operation".to_string()) + .or_insert_with(|| json!(request_context.operation())); + metadata +} + fn resolve_asset_read_target(query: &GetReadUrlQuery) -> Result { if let Some(object_key) = query .object_key @@ -1023,10 +1109,69 @@ mod tests { use time::OffsetDateTime; use tower::ServiceExt; + use crate::tracking::TrackingClientMarker; use crate::{app::build_router, config::AppConfig, state::AppState}; type HmacSha256 = Hmac; + #[test] + fn asset_tracking_metadata_receives_only_valid_agc_marker() { + let request_context = crate::request_context::RequestContext::new( + "request-225-asset".to_string(), + "POST /api/external/v1/assets/objects/confirm?x=1".to_string(), + std::time::Duration::ZERO, + false, + ); + let marked = super::apply_asset_tracking_client_marker( + json!({"asset": {"operation": "asset_upload_confirm"}}), + Some(TrackingClientMarker::Agc), + ); + assert_eq!(marked["client"], "agc"); + assert_eq!(marked["asset"]["operation"], "asset_upload_confirm"); + + let marked = super::apply_asset_tracking_route_metadata(marked, &request_context); + assert_eq!(marked["route"], "/api/external/v1/assets/objects/confirm"); + assert_eq!(marked["method"], "POST"); + assert_eq!(marked["status"], 200); + assert_eq!( + marked["operation"], + "POST /api/external/v1/assets/objects/confirm?x=1" + ); + + let unmarked = super::apply_asset_tracking_client_marker( + json!({"asset": {"operation": "asset_upload_confirm"}}), + None, + ); + assert!(unmarked.get("client").is_none()); + } + + #[test] + fn external_asset_tracking_keeps_owner_without_forging_user() { + let request_context = crate::request_context::RequestContext::new( + "request-225-owner".to_string(), + "POST /api/external/v1/assets/objects/confirm".to_string(), + std::time::Duration::ZERO, + false, + ); + let draft = super::build_asset_tracking_event_draft( + &request_context, + None, + "owner-225", + "asset_upload_confirm", + Some(TrackingClientMarker::Agc), + json!({"asset": {"operation": "asset_upload_confirm"}}), + ); + + assert_eq!(draft.user_id, None); + assert_eq!(draft.owner_user_id.as_deref(), Some("owner-225")); + assert_eq!( + draft.scope_kind, + module_runtime::RuntimeTrackingScopeKind::User + ); + assert_eq!(draft.scope_id, "owner-225"); + assert_eq!(draft.metadata["client"], "agc"); + } + fn asset_object_record( access_policy: module_assets::AssetObjectAccessPolicy, owner_user_id: Option<&str>, diff --git a/server-rs/crates/api-server/src/external_assets_api.rs b/server-rs/crates/api-server/src/external_assets_api.rs index 6c23f1ea8..68490dac0 100644 --- a/server-rs/crates/api-server/src/external_assets_api.rs +++ b/server-rs/crates/api-server/src/external_assets_api.rs @@ -17,6 +17,7 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, + tracking::TrackingClientMarker, }; const SCOPE_EDITOR_ASSET: &str = "editor:asset"; @@ -26,6 +27,7 @@ pub async fn create_external_direct_upload_ticket( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; @@ -33,6 +35,8 @@ pub async fn create_external_direct_upload_ticket( &state, &request_context, principal.owner_user_id(), + None, + client_marker.map(|Extension(marker)| marker), payload, ) .await @@ -42,12 +46,20 @@ pub async fn confirm_external_asset_object( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + client_marker: Option>, Json(mut payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; payload.owner_user_id = Some(principal.owner_user_id().to_string()); - confirm_asset_object_for_owner(&state, &request_context, principal.owner_user_id(), payload) - .await + confirm_asset_object_for_owner( + &state, + &request_context, + principal.owner_user_id(), + None, + client_marker.map(|Extension(marker)| marker), + payload, + ) + .await } pub async fn get_external_asset_read_url( diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 391686666..892961c1a 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -61,16 +61,6 @@ impl TrackingEventDraft { client_marker: None, } } - - pub fn user(event_key: &'static str, module_key: &'static str, user_id: &str) -> Self { - let normalized_user_id = user_id.trim().to_string(); - let mut draft = Self::new(event_key, module_key); - draft.scope_kind = RuntimeTrackingScopeKind::User; - draft.scope_id = normalized_user_id.clone(); - draft.user_id = Some(normalized_user_id.clone()); - draft.owner_user_id = Some(normalized_user_id); - draft - } } #[derive(Clone, Debug)] @@ -79,6 +69,7 @@ struct RouteTrackingSpec { module_key: &'static str, scope_kind: RuntimeTrackingScopeKind, scope_id: &'static str, + handled_by_existing_event: bool, } pub async fn record_external_generation_run_after_success( @@ -146,6 +137,9 @@ pub async fn record_route_tracking_event_after_success( let Some(spec) = resolve_route_tracking_spec(method, path) else { return; }; + if spec.handled_by_existing_event { + return; + } let identity = resolve_tracking_identity(authenticated, external_principal); let scope_id = resolve_tracking_scope_id(&spec, &identity); @@ -221,6 +215,12 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec( + "auth_password_login_success", + "auth", + User, + "anonymous", + )), ("GET", "/api/auth/me") => Some(route_spec("auth_me_view", "auth", User, "anonymous")), ("GET", "/api/auth/sessions") => { Some(route_spec("auth_sessions_view", "auth", User, "anonymous")) @@ -253,6 +253,12 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec( + "profile_api_key_create", + "profile", + User, + "anonymous", + )), ("GET", "/api/profile/wallet-ledger") => Some(route_spec( "wallet_ledger_view", "profile", @@ -271,6 +277,12 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec( + "recharge_order_wechat_confirm", + "profile", + User, + "anonymous", + )), ("POST", "/api/profile/feedback") => { Some(route_spec("feedback_submit", "profile", User, "anonymous")) } @@ -383,6 +395,24 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { + Some(manual_asset_route_spec("asset_upload_ticket_create")) + } + ("POST", "/api/assets/objects/confirm") => { + Some(manual_asset_route_spec("asset_upload_confirm")) + } + ("GET", "/api/assets/read-url") => Some(route_spec( + "asset_read_url_view", + "asset", + User, + "anonymous", + )), + ("GET", "/api/assets/read-bytes") => Some(route_spec( + "asset_read_bytes_view", + "asset", + User, + "anonymous", + )), ("POST", "/api/assets/character-visual/generate") => Some(route_spec( "asset_character_visual_generate", "asset", @@ -442,6 +472,90 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec( + "editor_projects_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/projects") => Some(route_spec( + "editor_project_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/editor/projects/{id}") => Some(route_spec( + "editor_project_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/projects/{id}/resources") => Some(route_spec( + "editor_project_resource_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/editor/assets/library") => Some(route_spec( + "editor_asset_library_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/assets/folders") => Some(route_spec( + "editor_asset_folder_create", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/images/generations") => Some(route_spec( + "editor_image_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/images/edits") => Some(route_spec( + "editor_image_edit_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/images/background-removals") => Some(route_spec( + "editor_image_background_removal_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/icon-spritesheets/generations") => Some(route_spec( + "editor_icon_spritesheet_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/character-animations/generations") => Some(route_spec( + "editor_character_animation_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/videos/generations") => Some(route_spec( + "editor_video_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/audios/sound-effects/generations") => Some(route_spec( + "editor_sound_effect_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/audios/background-music/generations") => Some(route_spec( + "editor_background_music_generation_submit", + "editor", + User, + "anonymous", + )), ("POST", "/api/llm/chat/completions") => { Some(route_spec("llm_request", "llm", User, "anonymous")) } @@ -475,6 +589,116 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec( + "external_generation_job_view", + "runtime", + User, + "anonymous", + )), + ("POST", "/api/external/v1/assets/direct-upload-tickets") => { + Some(manual_asset_route_spec("asset_upload_ticket_create")) + } + ("POST", "/api/external/v1/assets/objects/confirm") => { + Some(manual_asset_route_spec("asset_upload_confirm")) + } + ("GET", "/api/external/v1/assets/read-url") => Some(route_spec( + "asset_read_url_view", + "asset", + User, + "anonymous", + )), + ("GET", "/api/external/v1/editor/projects") => Some(route_spec( + "editor_projects_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/projects") => Some(route_spec( + "editor_project_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/external/v1/editor/projects/{id}") => Some(route_spec( + "editor_project_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/projects/{id}/resources") => Some(route_spec( + "editor_project_resource_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/external/v1/editor/assets/library") => Some(route_spec( + "editor_asset_library_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/assets/folders") => Some(route_spec( + "editor_asset_folder_create", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/images/generations") => Some(route_spec( + "editor_image_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/images/edits") => Some(route_spec( + "editor_image_edit_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/images/background-removals") => Some(route_spec( + "editor_image_background_removal_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/icon-spritesheets/generations") => Some(route_spec( + "editor_icon_spritesheet_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/character-animations/generations") => Some(route_spec( + "editor_character_animation_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/videos/generations") => Some(route_spec( + "editor_video_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/audios/sound-effects/generations") => Some(route_spec( + "editor_sound_effect_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/audios/background-music/generations") => { + Some(route_spec( + "editor_background_music_generation_submit", + "editor", + User, + "anonymous", + )) + } + ("GET", "/api/external/v1/generations/{id}") => Some(route_spec( + "external_generation_job_view", + "runtime", + User, + "anonymous", + )), #[cfg(any())] ("GET", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_view", @@ -547,6 +771,19 @@ fn route_spec( module_key, scope_kind, scope_id, + handled_by_existing_event: false, + } +} + +fn manual_asset_route_spec(event_key: &'static str) -> RouteTrackingSpec { + RouteTrackingSpec { + handled_by_existing_event: true, + ..route_spec( + event_key, + "asset", + RuntimeTrackingScopeKind::User, + "anonymous", + ) } } @@ -650,15 +887,19 @@ fn is_known_static_route_segment(segment: &str) -> bool { segment, "ai" | "analytics" | "api" + | "api-keys" | "asr" | "assets" | "auth" + | "audios" | "background-music" | "bidirection" | "bind-phone" + | "background-removals" | "browse-history" | "cancel" | "character-animation" + | "character-animations" | "character-visual" | "character-workflow-cache" | "chat" @@ -668,12 +909,23 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "completions" | "config" | "dashboard" + | "direct-upload-tickets" + | "edits" + | "editor" + | "external" + | "external-generation" | "fail" | "feedback" + | "folders" | "generate" + | "generations" | "history" | "import-video" + | "icon-spritesheets" + | "images" | "invite-center" + | "jobs" + | "library" | "llm" | "login" | "optimizations" @@ -682,20 +934,25 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "logout-all" | "me" | "metric" + | "objects" | "orders" | "phone" | "play-stats" | "profile" + | "projects" | "publish" | "recharge" | "recharge-center" | "redeem" | "redeem-code" | "redeem-codes" + | "read-bytes" + | "read-url" | "references" | "referrals" | "refresh" | "revoke" + | "resources" | "runtime" | "save" | "save-archives" @@ -714,6 +971,8 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "tasks" | "tts" | "volcengine" + | "v1" + | "videos" | "wallet-ledger" | "wechat" ) @@ -944,6 +1203,25 @@ mod tests { AuthenticatedAccessToken::new(claims) } + fn assert_route_spec( + method: Method, + path: &str, + event_key: &str, + module_key: &str, + scope_kind: module_runtime::RuntimeTrackingScopeKind, + handled_by_existing_event: bool, + ) { + let spec = resolve_route_tracking_spec(&method, path) + .unwrap_or_else(|| panic!("missing route tracking spec: {method} {path}")); + assert_eq!(spec.event_key, event_key, "{method} {path}"); + assert_eq!(spec.module_key, module_key, "{method} {path}"); + assert_eq!(spec.scope_kind, scope_kind, "{method} {path}"); + assert_eq!( + spec.handled_by_existing_event, handled_by_existing_event, + "{method} {path}" + ); + } + #[test] fn tracking_client_marker_accepts_only_trimmed_lowercase_agc() { let cases = [ @@ -1107,6 +1385,437 @@ mod tests { } } + #[test] + fn agc_account_routes_have_explicit_tracking_specs() { + use module_runtime::RuntimeTrackingScopeKind::User; + + for (method, path, event_key, module_key, handled_by_existing_event) in [ + (Method::GET, "/api/auth/me", "auth_me_view", "auth", false), + ( + Method::POST, + "/api/auth/entry", + "auth_password_login_success", + "auth", + false, + ), + ( + Method::POST, + "/api/auth/phone/login", + "auth_phone_login_success", + "auth", + false, + ), + ( + Method::POST, + "/api/auth/logout", + "auth_logout", + "auth", + false, + ), + ( + Method::GET, + "/api/editor/assets/library", + "editor_asset_library_view", + "editor", + false, + ), + ( + Method::GET, + "/api/assets/read-url", + "asset_read_url_view", + "asset", + false, + ), + ( + Method::GET, + "/api/assets/read-bytes", + "asset_read_bytes_view", + "asset", + false, + ), + ( + Method::GET, + "/api/profile/dashboard", + "profile_dashboard_view", + "profile", + false, + ), + ( + Method::GET, + "/api/profile/recharge-center", + "recharge_center_view", + "profile", + false, + ), + ( + Method::POST, + "/api/profile/recharge/orders", + "recharge_order_create", + "profile", + false, + ), + ( + Method::POST, + "/api/profile/recharge/orders/order-225/wechat/confirm", + "recharge_order_wechat_confirm", + "profile", + false, + ), + ( + Method::GET, + "/api/profile/wallet-ledger", + "wallet_ledger_view", + "profile", + false, + ), + ( + Method::POST, + "/api/profile/api-keys", + "profile_api_key_create", + "profile", + false, + ), + ( + Method::POST, + "/api/assets/direct-upload-tickets", + "asset_upload_ticket_create", + "asset", + true, + ), + ( + Method::POST, + "/api/assets/objects/confirm", + "asset_upload_confirm", + "asset", + true, + ), + ( + Method::GET, + "/api/editor/projects", + "editor_projects_view", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/projects", + "editor_project_create", + "editor", + false, + ), + ( + Method::GET, + "/api/editor/projects/project-225", + "editor_project_view", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/projects/project-225/resources", + "editor_project_resource_create", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/assets/folders", + "editor_asset_folder_create", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/images/generations", + "editor_image_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/images/edits", + "editor_image_edit_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/images/background-removals", + "editor_image_background_removal_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/icon-spritesheets/generations", + "editor_icon_spritesheet_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/character-animations/generations", + "editor_character_animation_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/videos/generations", + "editor_video_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/audios/sound-effects/generations", + "editor_sound_effect_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/audios/background-music/generations", + "editor_background_music_generation_submit", + "editor", + false, + ), + ( + Method::GET, + "/api/runtime/external-generation/jobs/job-225", + "external_generation_job_view", + "runtime", + false, + ), + ] { + assert_route_spec( + method, + path, + event_key, + module_key, + User, + handled_by_existing_event, + ); + } + + assert_route_spec( + Method::POST, + "/api/auth/refresh", + "auth_refresh_success", + "auth", + module_runtime::RuntimeTrackingScopeKind::Site, + false, + ); + assert_route_spec( + Method::POST, + "/api/auth/phone/send-code", + "auth_phone_code_send", + "auth", + module_runtime::RuntimeTrackingScopeKind::Site, + false, + ); + } + + #[test] + fn agc_external_routes_keep_external_paths_and_specs() { + use module_runtime::RuntimeTrackingScopeKind::User; + + for (method, path, event_key, module_key, handled_by_existing_event) in [ + ( + Method::POST, + "/api/external/v1/assets/direct-upload-tickets", + "asset_upload_ticket_create", + "asset", + true, + ), + ( + Method::POST, + "/api/external/v1/assets/objects/confirm", + "asset_upload_confirm", + "asset", + true, + ), + ( + Method::GET, + "/api/external/v1/assets/read-url", + "asset_read_url_view", + "asset", + false, + ), + ( + Method::GET, + "/api/external/v1/editor/projects", + "editor_projects_view", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/projects", + "editor_project_create", + "editor", + false, + ), + ( + Method::GET, + "/api/external/v1/editor/projects/project-225", + "editor_project_view", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/projects/project-225/resources", + "editor_project_resource_create", + "editor", + false, + ), + ( + Method::GET, + "/api/external/v1/editor/assets/library", + "editor_asset_library_view", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/assets/folders", + "editor_asset_folder_create", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/images/generations", + "editor_image_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/images/edits", + "editor_image_edit_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/images/background-removals", + "editor_image_background_removal_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/icon-spritesheets/generations", + "editor_icon_spritesheet_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/character-animations/generations", + "editor_character_animation_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/videos/generations", + "editor_video_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/audios/sound-effects/generations", + "editor_sound_effect_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/audios/background-music/generations", + "editor_background_music_generation_submit", + "editor", + false, + ), + ( + Method::GET, + "/api/external/v1/generations/task-225", + "external_generation_job_view", + "runtime", + false, + ), + ] { + assert_route_spec( + method, + path, + event_key, + module_key, + User, + handled_by_existing_event, + ); + } + + let spec = resolve_route_tracking_spec( + &Method::GET, + "/api/external/v1/editor/projects/project-225", + ) + .expect("external project route should resolve"); + let request_context = RequestContext::new( + "request-225-route".to_string(), + "GET /api/external/v1/editor/projects/project-225".to_string(), + Duration::ZERO, + false, + ); + let metadata = build_route_tracking_metadata( + &spec, + &request_context, + &Method::GET, + "/api/external/v1/editor/projects/project-225", + axum::http::StatusCode::OK, + Some(TrackingClientMarker::Agc), + ); + assert_eq!( + metadata["route"], + "/api/external/v1/editor/projects/project-225" + ); + assert_eq!(metadata["client"], "agc"); + } + + #[test] + fn external_discovery_and_mcp_routes_are_not_business_tracking_specs() { + for (method, path) in [ + (Method::GET, "/api/external/v1/openapi.json"), + (Method::GET, "/api/external/v1/agent-integration.json"), + (Method::GET, "/api/external/v1/skill/SKILL.md"), + (Method::GET, "/api/external/v1/skill.zip"), + (Method::POST, "/api/external/v1/mcp"), + ] { + assert!( + resolve_route_tracking_spec(&method, path).is_none(), + "{method} {path} must stay outside business tracking" + ); + } + } + + #[test] + fn agc_dynamic_route_segments_remain_normalized() { + assert_eq!( + normalize_route_path("/api/external/v1/editor/projects/project-225/resources"), + "/api/external/v1/editor/projects/{id}/resources" + ); + assert_eq!( + normalize_route_path("/api/external/v1/generations/task-225"), + "/api/external/v1/generations/{id}" + ); + assert_eq!( + normalize_route_path("/api/runtime/external-generation/jobs/job-225"), + "/api/runtime/external-generation/jobs/{id}" + ); + } + #[test] fn retired_play_paths_are_not_route_tracking_exclusions() { for path in [