tuitbot_server/routes/
telemetry.rs1use axum::http::StatusCode;
7use axum::Json;
8use serde::Deserialize;
9
10const MAX_BATCH_SIZE: usize = 50;
12
13const 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
30pub 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}