Skip to main content

systemprompt_api/routes/analytics/
events.rs

1use 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}