1use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum SignalEventType {
13 PageView,
15 RpcCall,
17 Track,
19 Identify,
21 SessionStart,
23 SessionEnd,
25 Error,
27 Breadcrumb,
29}
30
31impl std::fmt::Display for SignalEventType {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::PageView => write!(f, "page_view"),
35 Self::RpcCall => write!(f, "rpc_call"),
36 Self::Track => write!(f, "track"),
37 Self::Identify => write!(f, "identify"),
38 Self::SessionStart => write!(f, "session_start"),
39 Self::SessionEnd => write!(f, "session_end"),
40 Self::Error => write!(f, "error"),
41 Self::Breadcrumb => write!(f, "breadcrumb"),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SignalEvent {
49 pub event_type: SignalEventType,
50 pub event_name: Option<String>,
51 pub correlation_id: Option<String>,
52 pub session_id: Option<Uuid>,
53 pub visitor_id: Option<String>,
54 pub user_id: Option<Uuid>,
55 pub tenant_id: Option<Uuid>,
56 pub properties: serde_json::Value,
57
58 pub page_url: Option<String>,
60 pub referrer: Option<String>,
61
62 pub function_name: Option<String>,
64 pub function_kind: Option<String>,
65 pub duration_ms: Option<i32>,
66 pub status: Option<String>,
67
68 pub error_message: Option<String>,
70 pub error_stack: Option<String>,
71 pub error_context: Option<serde_json::Value>,
72
73 pub client_ip: Option<String>,
75 pub user_agent: Option<String>,
76
77 pub device_type: Option<String>,
79 pub browser: Option<String>,
80 pub os: Option<String>,
81
82 pub utm: Option<UtmParams>,
84
85 pub is_bot: bool,
87
88 pub timestamp: chrono::DateTime<chrono::Utc>,
89}
90
91impl SignalEvent {
92 #[allow(clippy::too_many_arguments)]
94 pub fn rpc_call(
95 function_name: &str,
96 function_kind: &str,
97 duration_ms: i32,
98 success: bool,
99 user_id: Option<Uuid>,
100 tenant_id: Option<Uuid>,
101 correlation_id: Option<String>,
102 client_ip: Option<String>,
103 user_agent: Option<String>,
104 visitor_id: Option<String>,
105 is_bot: bool,
106 ) -> Self {
107 let name = function_name.to_string();
108 Self {
109 event_type: SignalEventType::RpcCall,
110 event_name: Some(name.clone()),
111 correlation_id,
112 session_id: None,
113 visitor_id,
114 user_id,
115 tenant_id,
116 properties: serde_json::Value::Object(serde_json::Map::new()),
117 page_url: None,
118 referrer: None,
119 function_name: Some(name),
120 function_kind: Some(function_kind.to_string()),
121 duration_ms: Some(duration_ms),
122 status: Some(if success { "success" } else { "error" }.to_string()),
123 error_message: None,
124 error_stack: None,
125 error_context: None,
126 client_ip,
127 user_agent,
128 device_type: None,
129 browser: None,
130 os: None,
131 utm: None,
132 is_bot,
133 timestamp: chrono::Utc::now(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct UtmParams {
141 pub source: Option<String>,
142 pub medium: Option<String>,
143 pub campaign: Option<String>,
144 pub term: Option<String>,
145 pub content: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SignalEventBatch {
151 pub events: Vec<ClientEvent>,
152 pub context: Option<ClientContext>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ClientEvent {
158 pub event: String,
159 #[serde(default)]
160 pub properties: serde_json::Value,
161 pub correlation_id: Option<String>,
162 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct PageViewPayload {
168 pub url: String,
169 pub referrer: Option<String>,
170 pub title: Option<String>,
171 pub utm_source: Option<String>,
172 pub utm_medium: Option<String>,
173 pub utm_campaign: Option<String>,
174 pub utm_term: Option<String>,
175 pub utm_content: Option<String>,
176 pub correlation_id: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct IdentifyPayload {
182 pub user_id: String,
183 #[serde(default)]
184 pub traits: serde_json::Value,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct DiagnosticReport {
190 pub errors: Vec<DiagnosticError>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct DiagnosticError {
196 pub message: String,
197 pub stack: Option<String>,
198 pub context: Option<serde_json::Value>,
199 pub correlation_id: Option<String>,
200 pub breadcrumbs: Option<Vec<Breadcrumb>>,
201 pub page_url: Option<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Breadcrumb {
207 pub message: String,
208 #[serde(default)]
209 pub data: serde_json::Value,
210 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ClientContext {
216 pub page_url: Option<String>,
217 pub referrer: Option<String>,
218 pub session_id: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SignalResponse {
224 pub ok: bool,
225 pub session_id: Option<Uuid>,
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
231mod tests {
232 use super::*;
233
234 #[tokio::test]
235 async fn signal_event_type_display_produces_snake_case() {
236 assert_eq!(SignalEventType::PageView.to_string(), "page_view");
237 assert_eq!(SignalEventType::RpcCall.to_string(), "rpc_call");
238 assert_eq!(SignalEventType::Track.to_string(), "track");
239 assert_eq!(SignalEventType::Identify.to_string(), "identify");
240 assert_eq!(SignalEventType::SessionStart.to_string(), "session_start");
241 assert_eq!(SignalEventType::SessionEnd.to_string(), "session_end");
242 assert_eq!(SignalEventType::Error.to_string(), "error");
243 assert_eq!(SignalEventType::Breadcrumb.to_string(), "breadcrumb");
244 }
245
246 #[tokio::test]
247 async fn signal_event_type_serde_round_trip() {
248 let variants = [
249 SignalEventType::PageView,
250 SignalEventType::RpcCall,
251 SignalEventType::Track,
252 SignalEventType::Identify,
253 SignalEventType::SessionStart,
254 SignalEventType::SessionEnd,
255 SignalEventType::Error,
256 SignalEventType::Breadcrumb,
257 ];
258
259 for variant in variants {
260 let json = serde_json::to_string(&variant).unwrap();
261 let deserialized: SignalEventType = serde_json::from_str(&json).unwrap();
262 assert_eq!(variant, deserialized);
263 }
264 }
265
266 #[tokio::test]
267 async fn rpc_call_sets_correct_fields_on_success() {
268 let user_id = Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap();
269 let tenant_id = Uuid::parse_str("f1f2f3f4-a1a2-b1b2-c1c2-d1d2d3d4d5d6").unwrap();
270
271 let event = SignalEvent::rpc_call(
272 "get_users",
273 "query",
274 42,
275 true,
276 Some(user_id),
277 Some(tenant_id),
278 Some("corr-123".to_string()),
279 Some("127.0.0.1".to_string()),
280 Some("test-agent".to_string()),
281 Some("visitor-abc".to_string()),
282 false,
283 );
284
285 assert_eq!(event.event_type, SignalEventType::RpcCall);
286 assert_eq!(event.function_name.as_deref(), Some("get_users"));
287 assert_eq!(event.function_kind.as_deref(), Some("query"));
288 assert_eq!(event.duration_ms, Some(42));
289 assert_eq!(event.status.as_deref(), Some("success"));
290 assert!(event.device_type.is_none());
291 assert!(event.browser.is_none());
292 assert!(event.os.is_none());
293 assert!(event.session_id.is_none());
294 assert_eq!(
295 event.properties,
296 serde_json::Value::Object(serde_json::Map::new())
297 );
298 }
299
300 #[tokio::test]
301 async fn rpc_call_sets_error_status_when_not_success() {
302 let event = SignalEvent::rpc_call(
303 "create_user",
304 "mutation",
305 100,
306 false,
307 None,
308 None,
309 None,
310 None,
311 None,
312 None,
313 false,
314 );
315
316 assert_eq!(event.status.as_deref(), Some("error"));
317 }
318
319 #[tokio::test]
320 async fn client_event_deserializes_with_timestamp() {
321 let json = r#"{
322 "event": "click",
323 "properties": {"button": "submit"},
324 "correlation_id": "abc",
325 "timestamp": "2025-01-15T10:30:00Z"
326 }"#;
327
328 let event: ClientEvent = serde_json::from_str(json).unwrap();
329 assert_eq!(event.event, "click");
330 assert!(event.timestamp.is_some());
331 assert_eq!(event.correlation_id.as_deref(), Some("abc"));
332 }
333
334 #[tokio::test]
335 async fn client_event_deserializes_without_timestamp() {
336 let json = r#"{
337 "event": "click"
338 }"#;
339
340 let event: ClientEvent = serde_json::from_str(json).unwrap();
341 assert_eq!(event.event, "click");
342 assert!(event.timestamp.is_none());
343 assert_eq!(event.properties, serde_json::Value::Null);
344 }
345
346 #[tokio::test]
347 async fn page_view_payload_deserializes_with_all_utm_fields() {
348 let json = r#"{
349 "url": "https://example.com/page",
350 "referrer": "https://google.com",
351 "title": "Home",
352 "utm_source": "google",
353 "utm_medium": "cpc",
354 "utm_campaign": "spring",
355 "utm_term": "rust framework",
356 "utm_content": "banner",
357 "correlation_id": "corr-456"
358 }"#;
359
360 let payload: PageViewPayload = serde_json::from_str(json).unwrap();
361 assert_eq!(payload.url, "https://example.com/page");
362 assert_eq!(payload.referrer.as_deref(), Some("https://google.com"));
363 assert_eq!(payload.title.as_deref(), Some("Home"));
364 assert_eq!(payload.utm_source.as_deref(), Some("google"));
365 assert_eq!(payload.utm_medium.as_deref(), Some("cpc"));
366 assert_eq!(payload.utm_campaign.as_deref(), Some("spring"));
367 assert_eq!(payload.utm_term.as_deref(), Some("rust framework"));
368 assert_eq!(payload.utm_content.as_deref(), Some("banner"));
369 }
370
371 #[tokio::test]
372 async fn page_view_payload_deserializes_with_only_url() {
373 let json = r#"{"url": "https://example.com"}"#;
374
375 let payload: PageViewPayload = serde_json::from_str(json).unwrap();
376 assert_eq!(payload.url, "https://example.com");
377 assert!(payload.referrer.is_none());
378 assert!(payload.title.is_none());
379 assert!(payload.utm_source.is_none());
380 assert!(payload.utm_medium.is_none());
381 assert!(payload.utm_campaign.is_none());
382 assert!(payload.utm_term.is_none());
383 assert!(payload.utm_content.is_none());
384 }
385
386 #[tokio::test]
387 async fn diagnostic_error_deserializes_with_breadcrumbs() {
388 let json = r#"{
389 "message": "TypeError: null is not an object",
390 "stack": "at foo.js:10",
391 "breadcrumbs": [
392 {"message": "clicked button", "data": {}, "timestamp": null},
393 {"message": "navigated to /settings", "data": {"from": "/home"}}
394 ]
395 }"#;
396
397 let error: DiagnosticError = serde_json::from_str(json).unwrap();
398 assert_eq!(error.message, "TypeError: null is not an object");
399 assert_eq!(error.stack.as_deref(), Some("at foo.js:10"));
400 let breadcrumbs = error.breadcrumbs.unwrap();
401 assert_eq!(breadcrumbs.len(), 2);
402 assert_eq!(breadcrumbs[0].message, "clicked button");
403 assert_eq!(breadcrumbs[1].message, "navigated to /settings");
404 }
405
406 #[tokio::test]
407 async fn diagnostic_error_deserializes_with_null_breadcrumbs() {
408 let json = r#"{
409 "message": "ReferenceError: x is not defined",
410 "stack": null,
411 "context": null,
412 "correlation_id": null,
413 "breadcrumbs": null,
414 "page_url": null
415 }"#;
416
417 let error: DiagnosticError = serde_json::from_str(json).unwrap();
418 assert_eq!(error.message, "ReferenceError: x is not defined");
419 assert!(error.breadcrumbs.is_none());
420 }
421
422 #[tokio::test]
423 async fn signal_response_serializes_with_session_id() {
424 let session_id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
425 let response = SignalResponse {
426 ok: true,
427 session_id: Some(session_id),
428 };
429
430 let json = serde_json::to_string(&response).unwrap();
431 assert!(json.contains("\"ok\":true"));
432 assert!(json.contains("\"session_id\":\"11111111-2222-3333-4444-555555555555\""));
433 }
434
435 #[tokio::test]
436 async fn signal_response_serializes_not_ok_with_no_session() {
437 let response = SignalResponse {
438 ok: false,
439 session_id: None,
440 };
441
442 let json = serde_json::to_string(&response).unwrap();
443 assert!(json.contains("\"ok\":false"));
444 assert!(json.contains("\"session_id\":null"));
445 }
446
447 #[tokio::test]
448 async fn identify_payload_deserializes_with_traits() {
449 let json = r#"{
450 "user_id": "user-42",
451 "traits": {"plan": "pro", "team_size": 5}
452 }"#;
453
454 let payload: IdentifyPayload = serde_json::from_str(json).unwrap();
455 assert_eq!(payload.user_id, "user-42");
456 assert_eq!(payload.traits["plan"], "pro");
457 assert_eq!(payload.traits["team_size"], 5);
458 }
459
460 #[tokio::test]
461 async fn identify_payload_deserializes_without_traits() {
462 let json = r#"{"user_id": "user-99"}"#;
463
464 let payload: IdentifyPayload = serde_json::from_str(json).unwrap();
465 assert_eq!(payload.user_id, "user-99");
466 assert_eq!(payload.traits, serde_json::Value::Null);
467 }
468
469 #[tokio::test]
470 async fn client_context_deserializes_with_all_fields_none() {
471 let json = r#"{
472 "page_url": null,
473 "referrer": null,
474 "session_id": null
475 }"#;
476
477 let ctx: ClientContext = serde_json::from_str(json).unwrap();
478 assert!(ctx.page_url.is_none());
479 assert!(ctx.referrer.is_none());
480 assert!(ctx.session_id.is_none());
481 }
482}