systemprompt_api/routes/analytics/
events.rs1use axum::Json;
11use axum::extract::{Extension, State};
12use axum::http::StatusCode;
13use axum::response::IntoResponse;
14use std::sync::Arc;
15
16use systemprompt_analytics::{
17 AnalyticsEventBatchResponse, AnalyticsEventType, AnalyticsEventsRepository,
18 CreateAnalyticsEventBatchInput, CreateAnalyticsEventInput, CreateEngagementEventInput,
19 EngagementOptionalMetrics, EngagementRepository,
20};
21use systemprompt_content::ContentRepository;
22use systemprompt_identifiers::{ContentId, LocaleCode};
23use systemprompt_models::ContentRouting;
24use systemprompt_models::api::ApiError;
25use systemprompt_models::execution::context::RequestContext;
26
27#[derive(Clone)]
28pub struct AnalyticsState {
29 pub events: Arc<AnalyticsEventsRepository>,
30 pub content: Arc<ContentRepository>,
31 pub engagement: Arc<EngagementRepository>,
32 pub content_routing: Option<Arc<dyn ContentRouting>>,
33}
34
35impl std::fmt::Debug for AnalyticsState {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("AnalyticsState")
38 .field("content_routing", &self.content_routing.is_some())
39 .finish_non_exhaustive()
40 }
41}
42
43async fn resolve_content_id(
44 content_repo: &ContentRepository,
45 content_routing: Option<&dyn ContentRouting>,
46 page_url: &str,
47 slug: Option<&str>,
48) -> Option<ContentId> {
49 let resolved_slug;
50 let slug_to_use = if let Some(s) = slug {
51 s
52 } else {
53 resolved_slug = content_routing.and_then(|r| r.resolve_slug(page_url))?;
54 &resolved_slug
55 };
56
57 content_repo
58 .get_by_slug(slug_to_use, &LocaleCode::new("en"))
59 .await
60 .map_err(|e| {
61 tracing::warn!(error = %e, slug = %slug_to_use, "Failed to lookup content by slug");
62 e
63 })
64 .ok()
65 .flatten()
66 .map(|c| c.id)
67}
68
69pub(super) async fn record_event(
70 State(state): State<AnalyticsState>,
71 Extension(req_ctx): Extension<RequestContext>,
72 Json(mut input): Json<CreateAnalyticsEventInput>,
73) -> Result<impl IntoResponse, ApiError> {
74 if input.content_id.is_none() {
75 input.content_id = resolve_content_id(
76 &state.content,
77 state.content_routing.as_deref(),
78 &input.page_url,
79 input.slug.as_deref(),
80 )
81 .await;
82 }
83
84 let created = state
85 .events
86 .create_event(req_ctx.session_id(), req_ctx.user_id(), &input)
87 .await
88 .map_err(|e| {
89 tracing::error!(error = %e, "Failed to record analytics event");
90 ApiError::internal_error("Failed to record analytics event")
91 })?;
92
93 if input.event_type == AnalyticsEventType::PageExit {
94 fan_out_engagement(&state, &req_ctx, &input).await;
95 }
96
97 Ok((StatusCode::CREATED, Json(created)))
98}
99
100pub(super) async fn record_events_batch(
101 State(state): State<AnalyticsState>,
102 Extension(req_ctx): Extension<RequestContext>,
103 Json(mut input): Json<CreateAnalyticsEventBatchInput>,
104) -> Result<impl IntoResponse, ApiError> {
105 for event in &mut input.events {
106 if event.content_id.is_none() {
107 event.content_id = resolve_content_id(
108 &state.content,
109 state.content_routing.as_deref(),
110 &event.page_url,
111 event.slug.as_deref(),
112 )
113 .await;
114 }
115 }
116
117 let created = state
118 .events
119 .create_events_batch(req_ctx.session_id(), req_ctx.user_id(), &input.events)
120 .await
121 .map_err(|e| {
122 tracing::error!(error = %e, "Failed to record analytics events batch");
123 ApiError::internal_error("Failed to record analytics events")
124 })?;
125
126 for event in &input.events {
127 if event.event_type == AnalyticsEventType::PageExit {
128 fan_out_engagement(&state, &req_ctx, event).await;
129 }
130 }
131
132 Ok((
133 StatusCode::CREATED,
134 Json(AnalyticsEventBatchResponse {
135 recorded: created.len(),
136 events: created,
137 }),
138 ))
139}
140
141async fn fan_out_engagement(
142 state: &AnalyticsState,
143 req_ctx: &RequestContext,
144 input: &CreateAnalyticsEventInput,
145) {
146 let Some(ref data) = input.data else { return };
147
148 let get_i32 = |key: &str| -> Option<i32> {
149 data.get(key)
150 .and_then(serde_json::Value::as_i64)
151 .map(|v| v as i32)
152 };
153 let get_f32 = |key: &str| -> Option<f32> {
154 data.get(key)
155 .and_then(serde_json::Value::as_f64)
156 .map(|v| v as f32)
157 };
158 let get_bool =
159 |key: &str| -> Option<bool> { data.get(key).and_then(serde_json::Value::as_bool) };
160 let get_string =
161 |key: &str| -> Option<String> { data.get(key).and_then(|v| v.as_str()).map(String::from) };
162
163 let time_on_page = get_i32("time_on_page_ms").unwrap_or(0);
164 if time_on_page == 0 {
165 return;
166 }
167
168 let engagement_input = CreateEngagementEventInput {
169 page_url: input.page_url.clone(),
170 event_type: input.event_type.as_str().to_owned(),
171 time_on_page_ms: time_on_page,
172 max_scroll_depth: get_i32("max_scroll_depth").unwrap_or(0),
173 click_count: get_i32("click_count").unwrap_or(0),
174 event_data: None,
175 optional_metrics: EngagementOptionalMetrics {
176 time_to_first_interaction_ms: get_i32("time_to_first_interaction_ms"),
177 time_to_first_scroll_ms: get_i32("time_to_first_scroll_ms"),
178 scroll_velocity_avg: get_f32("scroll_velocity_avg"),
179 scroll_direction_changes: get_i32("scroll_direction_changes"),
180 mouse_move_distance_px: get_i32("mouse_move_distance_px"),
181 keyboard_events: get_i32("keyboard_events"),
182 copy_events: get_i32("copy_events"),
183 focus_time_ms: get_i32("focus_time_ms"),
184 blur_count: get_i32("blur_count"),
185 tab_switches: get_i32("tab_switches"),
186 visible_time_ms: get_i32("visible_time_ms"),
187 hidden_time_ms: get_i32("hidden_time_ms"),
188 is_rage_click: get_bool("is_rage_click"),
189 is_dead_click: get_bool("is_dead_click"),
190 reading_pattern: get_string("reading_pattern"),
191 },
192 };
193
194 let content_id = resolve_content_id(
195 &state.content,
196 state.content_routing.as_deref(),
197 &input.page_url,
198 input.slug.as_deref(),
199 )
200 .await;
201
202 if let Err(e) = state
203 .engagement
204 .create_engagement(
205 req_ctx.session_id(),
206 req_ctx.user_id(),
207 content_id.as_ref(),
208 &engagement_input,
209 )
210 .await
211 {
212 tracing::warn!(error = %e, "Failed to fan out engagement data from page_exit event");
213 }
214}