1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use systemprompt_identifiers::{
10 AnalyticsChangeId, AnalyticsFactId, DeviceId, ManagedResourceId, MarketplaceId,
11 NativeSessionId, PluginId, ResourceInvocationId, ResourceRevisionId, UserId,
12};
13
14use super::EvaluatorClient;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum AnalyticsFactKind {
19 Invocation,
20 Request,
21 Assessment,
22 ResourceAssociation,
23 Artifact,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct AnalyticsFactKey {
29 pub kind: AnalyticsFactKind,
30 pub source: String,
31 pub id: AnalyticsFactId,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35#[serde(tag = "status", rename_all = "snake_case")]
36pub enum InvocationConsumerIdentity {
37 HistoricalUnknown,
38 Authenticated {
39 consumer_id: UserId,
40 device_id: DeviceId,
41 host: EvaluatorClient,
42 session_id: NativeSessionId,
43 },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
47#[serde(tag = "status", rename_all = "snake_case")]
48pub enum InvocationResourceAttribution {
49 Unknown,
50 Verified {
51 resource_id: ManagedResourceId,
52 revision_id: ResourceRevisionId,
53 },
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct InvocationSkillIdentity {
69 pub plugin_id: PluginId,
70 pub skill: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub marketplace_id: Option<MarketplaceId>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub source: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub source_hash: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(deny_unknown_fields)]
81pub struct NormalizedInvocationFact {
82 pub invocation_id: ResourceInvocationId,
83 pub occurred_at: DateTime<Utc>,
84 pub consumer: InvocationConsumerIdentity,
85 pub attribution: InvocationResourceAttribution,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub skill: Option<InvocationSkillIdentity>,
88 pub succeeded: bool,
89 pub latency_micros: Option<u64>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
93#[serde(tag = "status", rename_all = "snake_case")]
94pub enum RecordedSpend {
95 Known {
96 currency: String,
97 amount_micros: u64,
98 },
99 UnknownPricing,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
103#[serde(deny_unknown_fields)]
104pub struct NormalizedRequestFact {
105 pub request_key: AnalyticsFactKey,
106 pub occurred_at: DateTime<Utc>,
107 pub consumer: InvocationConsumerIdentity,
108 pub succeeded: bool,
109 pub spend: RecordedSpend,
110 pub input_tokens: Option<u64>,
111 pub output_tokens: Option<u64>,
112 pub latency_micros: Option<u64>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
116#[serde(tag = "status", rename_all = "snake_case")]
117pub enum AssessmentOutcome {
118 Scored { score_millionths: i64 },
119 Failed,
120 Unavailable,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
124#[serde(deny_unknown_fields)]
125pub struct AssessmentConversationKey {
126 pub source: String,
127 pub id: AnalyticsFactId,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
131#[serde(deny_unknown_fields)]
132pub struct NormalizedAssessmentFact {
133 pub conversation_key: AssessmentConversationKey,
134 pub assessment_key: AnalyticsFactKey,
135 pub invocation_key: AnalyticsFactKey,
136 pub occurred_at: DateTime<Utc>,
137 pub outcome: AssessmentOutcome,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
141#[serde(deny_unknown_fields)]
142pub struct NormalizedResourceAssociationFact {
143 pub association_key: AnalyticsFactKey,
144 pub invocation_key: AnalyticsFactKey,
145 pub request_key: AnalyticsFactKey,
146 pub occurred_at: DateTime<Utc>,
147 pub attribution: InvocationResourceAttribution,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct NormalizedArtifactFact {
158 pub artifact_key: AnalyticsFactKey,
159 pub execution_id: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub invocation_key: Option<AnalyticsFactKey>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub request_key: Option<AnalyticsFactKey>,
164 pub occurred_at: DateTime<Utc>,
165 pub consumer: InvocationConsumerIdentity,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub skill: Option<InvocationSkillIdentity>,
168 pub tool_name: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub server_name: Option<String>,
171 pub artifact_type: String,
172 pub source: crate::mcp::ExecutionSource,
173 pub correlation: crate::mcp::Correlation,
174 pub is_structured: bool,
175 pub has_ui_resource: bool,
176 pub succeeded: bool,
177 pub payload_bytes: Option<u64>,
178 pub findings: u64,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
182#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
183pub enum NormalizedAnalyticsFact {
184 Invocation(NormalizedInvocationFact),
185 Request(NormalizedRequestFact),
186 Assessment(NormalizedAssessmentFact),
187 ResourceAssociation(NormalizedResourceAssociationFact),
188 Artifact(Box<NormalizedArtifactFact>),
189}
190
191impl NormalizedAnalyticsFact {
192 pub const fn kind(&self) -> AnalyticsFactKind {
193 match self {
194 Self::Invocation(_) => AnalyticsFactKind::Invocation,
195 Self::Request(_) => AnalyticsFactKind::Request,
196 Self::Assessment(_) => AnalyticsFactKind::Assessment,
197 Self::ResourceAssociation(_) => AnalyticsFactKind::ResourceAssociation,
198 Self::Artifact(_) => AnalyticsFactKind::Artifact,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
204#[serde(tag = "operation", rename_all = "snake_case")]
205#[expect(
206 clippy::large_enum_variant,
207 reason = "wire contract: a tombstone carries nothing by definition and the fact is matched by value"
208)]
209pub enum AnalyticsChangeOperation {
210 Replace { fact: NormalizedAnalyticsFact },
211 Tombstone,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
215#[serde(deny_unknown_fields)]
216pub struct AnalyticsChange {
217 pub change_id: AnalyticsChangeId,
218 pub key: AnalyticsFactKey,
219 pub revision: u64,
220 pub occurred_at: DateTime<Utc>,
221 pub recorded_at: DateTime<Utc>,
222 pub operation: AnalyticsChangeOperation,
223}
224
225impl AnalyticsChange {
226 pub fn validate(&self) -> Result<(), super::FeedbackContractError> {
227 if self.revision == 0 || self.key.source.is_empty() || self.key.source.len() > 128 {
228 return Err(super::FeedbackContractError::Bounds);
229 }
230 if let AnalyticsChangeOperation::Replace { fact } = &self.operation
231 && fact.kind() != self.key.kind
232 {
233 return Err(super::FeedbackContractError::IncompleteManifest);
234 }
235 Ok(())
236 }
237}