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