Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs
T
lhk229 1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
客户端埋点设置 (#446)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-23 00:09:12 +08:00

530 lines
16 KiB
Rust

//! 产品事件合同。正文和凭据不属于此模块的输入,nullable 字段仍必须显式存在。
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer)
}
macro_rules! values {
($name:ident { $($variant:ident),+ $(,)? }) => {
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum $name { $($variant),+ }
};
}
values!(Source {
Editor,
Direct,
DesignAgent,
AssetCanvas,
ResourceEditor,
UiEditor,
Manual,
System
});
values!(Status { Success, Failed });
values!(EntrySource {
DirectLaunch,
ProjectAssociation,
AppRestore
});
values!(SessionEndReason {
UserExit,
AppRestart
});
values!(FocusReason {
InitialFocus,
WindowFocus,
Restore,
AccountChange
});
values!(BlurReason {
WindowBlur,
Minimized,
AppExit,
SystemSuspend,
AccountChange
});
values!(CreationSource {
HomeGame,
HomeDesign,
Template,
SelectedDirectory
});
values!(OpenSource {
Create,
Picker,
Recent,
AppRestore,
ProjectAssociation
});
values!(AgentType {
GameAgent,
DesignAgent
});
values!(RunSource {
UserSubmit,
UserContinue,
Clarification,
Approval,
UserRetry
});
values!(RunEndReason {
Finished,
WaitingForUser,
WaitingForApproval,
Failed
});
values!(ErrorCode {
ProviderAuthFailed,
ProviderRateLimited,
ProviderUnavailable,
ProviderTimeout,
ProviderInvalidResponse,
LocalIoFailed,
RuntimeFailed,
RuntimeErrorUnclassified
});
values!(RevisionSource {
Agent,
AssetCanvas,
ResourceEditor,
UiEditor,
ManualEdit,
SystemProjection
});
values!(ChangeKind {
Code,
Asset,
Ui,
DesignDocument,
Mixed
});
values!(PreviewSource {
User,
Agent,
AutoRestore
});
values!(SaveSource {
Manual,
Auto,
Checkpoint
});
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Route {
#[serde(deserialize_with = "required_nullable")]
pub destination_origin: Option<String>,
#[serde(deserialize_with = "required_nullable")]
pub user_id: Option<String>,
}
impl Route {
pub fn from_identity(user_id: Option<String>, api_base_url: Option<&str>) -> Self {
let destination_origin = api_base_url.and_then(|raw| {
let parsed = url::Url::parse(raw).ok()?;
(matches!(parsed.scheme(), "http" | "https")
&& parsed.host_str().is_some()
&& parsed.username().is_empty()
&& parsed.password().is_none())
.then(|| parsed.origin().ascii_serialization())
});
Self {
user_id,
destination_origin,
}
}
pub fn validate(&self) -> bool {
optional_id(&self.user_id)
&& self.destination_origin.as_ref().is_none_or(|origin| {
origin.len() <= 2048
&& Self::from_identity(None, Some(origin))
.destination_origin
.as_ref()
== Some(origin)
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SessionStart {
pub entry_source: EntrySource,
#[serde(deserialize_with = "required_nullable")]
pub first_project_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SessionEnd {
pub end_reason: SessionEndReason,
#[serde(deserialize_with = "required_nullable")]
pub session_duration_ms: Option<u64>,
#[serde(deserialize_with = "required_nullable")]
pub last_project_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct FocusStart {
pub focus_interval_id: String,
pub focus_reason: FocusReason,
#[serde(deserialize_with = "required_nullable")]
pub active_project_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct FocusEnd {
pub focus_interval_id: String,
pub blur_reason: BlurReason,
#[serde(deserialize_with = "required_nullable")]
pub focus_duration_ms: Option<u64>,
#[serde(deserialize_with = "required_nullable")]
pub active_project_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ProjectCreated {
pub creation_source: CreationSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_template_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ProjectOpened {
pub open_source: OpenSource,
#[serde(deserialize_with = "required_nullable")]
pub is_first_open: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct EmptyProperties {}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunFinished {
pub agent_type: AgentType,
pub run_source: RunSource,
#[serde(deserialize_with = "required_nullable")]
pub duration_ms: Option<u64>,
pub retry_index: u64,
#[serde(deserialize_with = "required_nullable")]
pub output_change_detected: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision_id: Option<String>,
pub end_reason: RunEndReason,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RevisionCreated {
pub revision_id: String,
pub revision_source: RevisionSource,
pub change_kind: ChangeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub files_changed_count: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PreviewReady {
pub preview_source: PreviewSource,
pub preview_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ready_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ProjectSaved {
pub save_source: SaveSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(
tag = "event_name",
content = "properties",
rename_all = "snake_case",
deny_unknown_fields
)]
pub(crate) enum EventData {
EditorSessionStart(SessionStart),
EditorSessionEnd(SessionEnd),
EditorFocusStart(FocusStart),
EditorFocusEnd(FocusEnd),
ProjectCreateSuccess(ProjectCreated),
ProjectOpen(ProjectOpened),
CreativeTaskSubmit(EmptyProperties),
AgentRunCompleted(RunFinished),
AgentRunFailed(RunFinished),
ProjectRevisionCreated(RevisionCreated),
PreviewReady(PreviewReady),
ProjectSave(ProjectSaved),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Event {
pub schema_version: u32,
pub event_id: String,
pub event_name: String,
pub event_time: String,
#[serde(deserialize_with = "required_nullable")]
pub user_id: Option<String>,
pub editor_session_id: String,
#[serde(deserialize_with = "required_nullable")]
pub project_id: Option<String>,
#[serde(deserialize_with = "required_nullable")]
pub creative_task_id: Option<String>,
#[serde(deserialize_with = "required_nullable")]
pub agent_run_id: Option<String>,
#[serde(deserialize_with = "required_nullable")]
pub agent_turn_id: Option<String>,
#[serde(deserialize_with = "required_nullable")]
pub status: Option<Status>,
#[serde(deserialize_with = "required_nullable")]
pub error_code: Option<ErrorCode>,
pub source: Source,
pub client_version: String,
pub properties: Value,
}
/// 每次操作先冻结此上下文。它不持有登录凭据,也不在后台重新读取当前账号。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Context {
pub route: Route,
pub editor_session_id: String,
pub client_version: String,
}
#[derive(Clone, Debug)]
pub(crate) struct RunIdentity {
pub run_id: String,
pub turn_id: Option<String>,
pub error_code: Option<ErrorCode>,
}
impl Context {
pub fn capture(
&self,
data: EventData,
project_id: Option<String>,
source: Source,
run: Option<RunIdentity>,
) -> Result<Event, &'static str> {
if !self.route.validate() {
return Err("invalid_route");
}
let status = data.status();
let creative_task_id = data.has_goal().then(|| project_id.clone()).flatten();
let payload = serde_json::to_value(data).map_err(|_| "serialize_failed")?;
let event = Event {
schema_version: 1,
event_id: Uuid::new_v4().to_string(),
event_name: payload["event_name"]
.as_str()
.ok_or("invalid_event")?
.to_string(),
event_time: timestamp_now(),
user_id: self.route.user_id.clone(),
editor_session_id: self.editor_session_id.clone(),
project_id,
creative_task_id,
agent_run_id: run.as_ref().map(|r| r.run_id.clone()),
agent_turn_id: run.as_ref().and_then(|r| r.turn_id.clone()),
error_code: run.and_then(|r| r.error_code),
status,
source,
client_version: self.client_version.clone(),
properties: payload["properties"].clone(),
};
event.validate()?;
Ok(event)
}
}
impl EventData {
fn status(&self) -> Option<Status> {
match self {
Self::EditorFocusStart(_) | Self::EditorFocusEnd(_) => None,
Self::AgentRunFailed(_) => Some(Status::Failed),
_ => Some(Status::Success),
}
}
fn has_goal(&self) -> bool {
matches!(
self,
Self::CreativeTaskSubmit(_)
| Self::AgentRunCompleted(_)
| Self::AgentRunFailed(_)
| Self::ProjectRevisionCreated(_)
| Self::PreviewReady(_)
| Self::ProjectSave(_)
)
}
}
impl Event {
pub fn data(&self) -> Result<EventData, &'static str> {
// 可选字段不可得时省略;显式 null 仅用于合同指定的 nullable 字段。
let optional = match self.event_name.as_str() {
"project_create_success" => &["project_template_id"][..],
"agent_run_completed" | "agent_run_failed" | "project_save" => &["revision_id"][..],
"project_revision_created" => &["files_changed_count"][..],
"preview_ready" => &["ready_duration_ms"][..],
_ => &[][..],
};
if optional
.iter()
.any(|key| self.properties.get(key).is_some_and(Value::is_null))
{
return Err("invalid_optional_property");
}
serde_json::from_value(serde_json::json!({
"event_name": self.event_name,
"properties": self.properties,
}))
.map_err(|_| "invalid_properties")
}
pub fn validate(&self) -> Result<(), &'static str> {
if self.schema_version != 1
|| !uuid(&self.event_id)
|| !uuid(&self.editor_session_id)
|| !id(&self.client_version)
|| !optional_id(&self.user_id)
|| !optional_id(&self.project_id)
|| !optional_id(&self.creative_task_id)
|| !optional_id(&self.agent_turn_id)
|| !valid_time(&self.event_time)
{
return Err("invalid_envelope");
}
let data = self.data()?;
if self.status != data.status()
|| self.error_code.is_some() != matches!(data, EventData::AgentRunFailed(_))
{
return Err("invalid_status");
}
if data.has_goal() {
if self.project_id.is_none() || self.creative_task_id != self.project_id {
return Err("invalid_goal");
}
} else if self.creative_task_id.is_some() {
return Err("unexpected_goal");
}
let is_run = matches!(
data,
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
);
if is_run {
if !self.agent_run_id.as_deref().is_some_and(uuid) {
return Err("invalid_run");
}
} else if self.agent_run_id.is_some() || self.agent_turn_id.is_some() {
return Err("unexpected_run");
}
let editor = self.source == Source::Editor;
let agent = matches!(self.source, Source::Direct | Source::DesignAgent);
let valid = match data {
EventData::EditorSessionStart(p) => editor && p.first_project_id == self.project_id,
EventData::EditorSessionEnd(p) => {
editor && p.last_project_id == self.project_id && safe(p.session_duration_ms)
}
EventData::EditorFocusStart(p) => {
editor && uuid(&p.focus_interval_id) && p.active_project_id == self.project_id
}
EventData::EditorFocusEnd(p) => {
editor
&& uuid(&p.focus_interval_id)
&& p.active_project_id == self.project_id
&& safe(p.focus_duration_ms)
}
EventData::ProjectCreateSuccess(p) => {
editor && self.project_id.is_some() && optional_id(&p.project_template_id)
}
EventData::ProjectOpen(_) => editor && self.project_id.is_some(),
EventData::CreativeTaskSubmit(_) => agent,
EventData::AgentRunCompleted(p) | EventData::AgentRunFailed(p) => {
agent
&& ((self.source == Source::Direct) == (p.agent_type == AgentType::GameAgent))
&& ((self.status == Some(Status::Failed))
== (p.end_reason == RunEndReason::Failed))
&& safe(p.duration_ms)
&& safe(Some(p.retry_index))
&& optional_id(&p.revision_id)
}
EventData::ProjectRevisionCreated(p) => {
id(&p.revision_id)
&& safe(p.files_changed_count)
&& match p.revision_source {
RevisionSource::Agent => agent,
RevisionSource::AssetCanvas => self.source == Source::AssetCanvas,
RevisionSource::ResourceEditor => self.source == Source::ResourceEditor,
RevisionSource::UiEditor => self.source == Source::UiEditor,
RevisionSource::ManualEdit => self.source == Source::Manual,
RevisionSource::SystemProjection => self.source == Source::System,
}
}
EventData::PreviewReady(p) => id(&p.preview_version) && safe(p.ready_duration_ms),
EventData::ProjectSave(p) => optional_id(&p.revision_id),
};
if valid {
Ok(())
} else {
Err("invalid_event_fields")
}
}
}
fn id(value: &str) -> bool {
!value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
}
fn optional_id(value: &Option<String>) -> bool {
value.as_deref().is_none_or(id)
}
fn uuid(value: &str) -> bool {
Uuid::parse_str(value).is_ok_and(|v| v.get_version_num() == 4 && v.to_string() == value)
}
fn safe(value: Option<u64>) -> bool {
value.is_none_or(|v| v <= MAX_SAFE_INTEGER)
}
pub(super) fn valid_time(value: &str) -> bool {
DateTime::parse_from_rfc3339(value)
.is_ok_and(|v| v.to_utc().to_rfc3339_opts(SecondsFormat::Millis, true) == value)
}
pub(crate) fn timestamp_now() -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
DateTime::<Utc>::from_timestamp_millis(ms.min(i64::MAX as u128) as i64)
.unwrap_or_default()
.to_rfc3339_opts(SecondsFormat::Millis, true)
}
#[cfg(test)]
#[path = "contract_tests.rs"]
mod tests;