Skip to main content

tuitbot_server/routes/
telemetry.rs

1//! Lightweight telemetry ingestion endpoint.
2//!
3//! Receives batched frontend funnel events and logs them via `tracing`.
4//! No database writes — structured logging only, for future aggregation.
5
6use axum::http::StatusCode;
7use axum::Json;
8use serde::Deserialize;
9
10/// Maximum events per batch to prevent abuse.
11const MAX_BATCH_SIZE: usize = 50;
12
13/// Allowed event name prefixes (namespace isolation).
14const ALLOWED_PREFIXES: &[&str] = &["backlink.", "hook_miner.", "forge.", "evidence."];
15
16#[derive(Deserialize)]
17pub struct TelemetryBatch {
18    events: Vec<TelemetryEvent>,
19}
20
21#[derive(Deserialize)]
22pub struct TelemetryEvent {
23    event: String,
24    #[allow(dead_code)]
25    properties: Option<serde_json::Map<String, serde_json::Value>>,
26    #[allow(dead_code)]
27    timestamp: String,
28}
29
30/// `POST /api/telemetry/events` — ingest a batch of frontend funnel events.
31///
32/// Validates batch size and event name prefix, then logs each event via
33/// `tracing::info!`. Returns 204 No Content on success.
34pub async fn ingest_events(
35    Json(batch): Json<TelemetryBatch>,
36) -> Result<StatusCode, (StatusCode, String)> {
37    if batch.events.len() > MAX_BATCH_SIZE {
38        return Err((
39            StatusCode::BAD_REQUEST,
40            format!(
41                "Batch too large: {} events (max {})",
42                batch.events.len(),
43                MAX_BATCH_SIZE
44            ),
45        ));
46    }
47
48    for ev in &batch.events {
49        if !ALLOWED_PREFIXES.iter().any(|p| ev.event.starts_with(p)) {
50            return Err((
51                StatusCode::BAD_REQUEST,
52                format!(
53                    "Invalid event name \"{}\": must start with one of {:?}",
54                    ev.event, ALLOWED_PREFIXES
55                ),
56            ));
57        }
58    }
59
60    for ev in &batch.events {
61        tracing::info!(
62            event = %ev.event,
63            timestamp = %ev.timestamp,
64            properties = ?ev.properties,
65            "telemetry_event"
66        );
67    }
68
69    Ok(StatusCode::NO_CONTENT)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use axum::Json;
76
77    fn make_event(name: &str) -> TelemetryEvent {
78        TelemetryEvent {
79            event: name.to_string(),
80            properties: Some(serde_json::Map::new()),
81            timestamp: "2026-03-21T00:00:00Z".to_string(),
82        }
83    }
84
85    #[tokio::test]
86    async fn valid_batch_returns_204() {
87        let batch = TelemetryBatch {
88            events: vec![
89                make_event("backlink.suggestions_shown"),
90                make_event("backlink.suggestion_accepted"),
91            ],
92        };
93        let result = ingest_events(Json(batch)).await;
94        assert_eq!(result.unwrap(), StatusCode::NO_CONTENT);
95    }
96
97    #[tokio::test]
98    async fn oversized_batch_returns_400() {
99        let events: Vec<TelemetryEvent> = (0..51)
100            .map(|i| make_event(&format!("backlink.event_{i}")))
101            .collect();
102        let batch = TelemetryBatch { events };
103        let result = ingest_events(Json(batch)).await;
104        let err = result.unwrap_err();
105        assert_eq!(err.0, StatusCode::BAD_REQUEST);
106        assert!(err.1.contains("too large"));
107    }
108
109    #[tokio::test]
110    async fn invalid_prefix_returns_400() {
111        let batch = TelemetryBatch {
112            events: vec![make_event("malicious.event")],
113        };
114        let result = ingest_events(Json(batch)).await;
115        let err = result.unwrap_err();
116        assert_eq!(err.0, StatusCode::BAD_REQUEST);
117        assert!(err.1.contains("must start with"));
118    }
119
120    #[tokio::test]
121    async fn empty_batch_returns_204() {
122        let batch = TelemetryBatch { events: vec![] };
123        let result = ingest_events(Json(batch)).await;
124        assert_eq!(result.unwrap(), StatusCode::NO_CONTENT);
125    }
126
127    #[tokio::test]
128    async fn hook_miner_prefix_accepted() {
129        let batch = TelemetryBatch {
130            events: vec![make_event("hook_miner.angles_shown")],
131        };
132        let result = ingest_events(Json(batch)).await;
133        assert_eq!(result.unwrap(), StatusCode::NO_CONTENT);
134    }
135
136    #[tokio::test]
137    async fn forge_prefix_accepted() {
138        let batch = TelemetryBatch {
139            events: vec![make_event("forge.sync_succeeded")],
140        };
141        let result = ingest_events(Json(batch)).await;
142        assert_eq!(result.unwrap(), StatusCode::NO_CONTENT);
143    }
144
145    #[tokio::test]
146    async fn mixed_namespace_batch_accepted() {
147        let batch = TelemetryBatch {
148            events: vec![
149                make_event("backlink.suggestions_shown"),
150                make_event("hook_miner.angle_selected"),
151                make_event("forge.enabled"),
152            ],
153        };
154        let result = ingest_events(Json(batch)).await;
155        assert_eq!(result.unwrap(), StatusCode::NO_CONTENT);
156    }
157
158    #[tokio::test]
159    async fn evidence_prefix_accepted() {
160        let batch = TelemetryBatch {
161            events: vec![make_event("evidence.search_latency")],
162        };
163        let result = ingest_events(Json(batch)).await;
164        assert_eq!(result.unwrap(), StatusCode::NO_CONTENT);
165    }
166
167    #[tokio::test]
168    async fn unknown_prefix_rejected() {
169        let batch = TelemetryBatch {
170            events: vec![make_event("other.event")],
171        };
172        let result = ingest_events(Json(batch)).await;
173        let err = result.unwrap_err();
174        assert_eq!(err.0, StatusCode::BAD_REQUEST);
175        assert!(err.1.contains("must start with one of"));
176    }
177}