1use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12#[non_exhaustive]
13pub enum SignalEventType {
14 PageView,
16 RpcCall,
18 Track,
20 SessionStart,
22 SessionEnd,
24 Error,
26 Breadcrumb,
28 ServerExecution,
30}
31
32impl std::fmt::Display for SignalEventType {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::PageView => write!(f, "page_view"),
36 Self::RpcCall => write!(f, "rpc_call"),
37 Self::Track => write!(f, "track"),
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 Self::ServerExecution => write!(f, "server_execution"),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SignalEvent {
50 pub event_type: SignalEventType,
51 pub event_name: Option<String>,
52 pub correlation_id: Option<String>,
53 pub session_id: Option<Uuid>,
54 pub visitor_id: Option<String>,
55 pub user_id: Option<Uuid>,
56 pub tenant_id: Option<Uuid>,
57 pub properties: serde_json::Value,
58
59 pub page_url: Option<String>,
61 pub referrer: Option<String>,
62
63 pub function_name: Option<String>,
65 pub function_kind: Option<String>,
66 pub duration_ms: Option<i32>,
67 pub status: Option<String>,
68
69 pub error_message: Option<String>,
71 pub error_stack: Option<String>,
72 pub error_context: Option<serde_json::Value>,
73
74 pub client_ip: Option<String>,
76 pub country: Option<String>,
77 pub city: Option<String>,
78 pub user_agent: Option<String>,
79
80 pub device_type: Option<String>,
82 pub browser: Option<String>,
83 pub os: Option<String>,
84
85 pub utm: Option<UtmParams>,
87
88 pub is_bot: bool,
90
91 pub timestamp: chrono::DateTime<chrono::Utc>,
92}
93
94impl SignalEvent {
95 pub fn server_execution(
99 name: &str,
100 kind: &str,
101 duration_ms: i32,
102 success: bool,
103 error_message: Option<String>,
104 ) -> Self {
105 let n = name.to_string();
106 Self {
107 event_type: SignalEventType::ServerExecution,
108 event_name: Some(n.clone()),
109 correlation_id: None,
110 session_id: None,
111 visitor_id: None,
112 user_id: None,
113 tenant_id: None,
114 properties: serde_json::Value::Object(serde_json::Map::new()),
115 page_url: None,
116 referrer: None,
117 function_name: Some(n),
118 function_kind: Some(kind.to_string()),
119 duration_ms: Some(duration_ms),
120 status: Some(if success { "success" } else { "error" }.to_string()),
121 error_message,
122 error_stack: None,
123 error_context: None,
124 client_ip: None,
125 country: None,
126 city: None,
127 user_agent: None,
128 device_type: None,
129 browser: None,
130 os: None,
131 utm: None,
132 is_bot: false,
133 timestamp: chrono::Utc::now(),
134 }
135 }
136
137 pub fn diagnostic(
140 event_name: &str,
141 properties: serde_json::Value,
142 client_ip: Option<String>,
143 user_agent: Option<String>,
144 visitor_id: Option<String>,
145 user_id: Option<Uuid>,
146 is_bot: bool,
147 ) -> Self {
148 Self {
149 event_type: SignalEventType::Track,
150 event_name: Some(event_name.to_string()),
151 correlation_id: None,
152 session_id: None,
153 visitor_id,
154 user_id,
155 tenant_id: None,
156 properties,
157 page_url: None,
158 referrer: None,
159 function_name: None,
160 function_kind: None,
161 duration_ms: None,
162 status: None,
163 error_message: None,
164 error_stack: None,
165 error_context: None,
166 client_ip,
167 country: None,
168 city: None,
169 user_agent,
170 device_type: None,
171 browser: None,
172 os: None,
173 utm: None,
174 is_bot,
175 timestamp: chrono::Utc::now(),
176 }
177 }
178
179 #[allow(clippy::too_many_arguments)]
181 pub fn rpc_call(
182 function_name: &str,
183 function_kind: &str,
184 duration_ms: i32,
185 success: bool,
186 user_id: Option<Uuid>,
187 tenant_id: Option<Uuid>,
188 correlation_id: Option<String>,
189 client_ip: Option<String>,
190 user_agent: Option<String>,
191 visitor_id: Option<String>,
192 is_bot: bool,
193 ) -> Self {
194 let name = function_name.to_string();
195 Self {
196 event_type: SignalEventType::RpcCall,
197 event_name: Some(name.clone()),
198 correlation_id,
199 session_id: None,
200 visitor_id,
201 user_id,
202 tenant_id,
203 properties: serde_json::Value::Object(serde_json::Map::new()),
204 page_url: None,
205 referrer: None,
206 function_name: Some(name),
207 function_kind: Some(function_kind.to_string()),
208 duration_ms: Some(duration_ms),
209 status: Some(if success { "success" } else { "error" }.to_string()),
210 error_message: None,
211 error_stack: None,
212 error_context: None,
213 client_ip,
214 country: None,
215 city: None,
216 user_agent,
217 device_type: None,
218 browser: None,
219 os: None,
220 utm: None,
221 is_bot,
222 timestamp: chrono::Utc::now(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct UtmParams {
230 pub source: Option<String>,
231 pub medium: Option<String>,
232 pub campaign: Option<String>,
233 pub term: Option<String>,
234 pub content: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(tag = "type", content = "payload")]
245pub enum SignalPayload {
246 #[serde(rename = "event")]
248 Event(SignalEventBatch),
249 #[serde(rename = "view")]
251 View(PageViewPayload),
252 #[serde(rename = "report")]
254 Report(DiagnosticReport),
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct SignalEventBatch {
260 pub events: Vec<ClientEvent>,
261 pub context: Option<ClientContext>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ClientEvent {
267 pub event: String,
268 #[serde(default)]
269 pub properties: serde_json::Value,
270 pub correlation_id: Option<String>,
271 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct PageViewPayload {
277 pub url: String,
278 pub referrer: Option<String>,
279 pub title: Option<String>,
280 pub utm_source: Option<String>,
281 pub utm_medium: Option<String>,
282 pub utm_campaign: Option<String>,
283 pub utm_term: Option<String>,
284 pub utm_content: Option<String>,
285 pub correlation_id: Option<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct DiagnosticReport {
291 pub errors: Vec<DiagnosticError>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct DiagnosticError {
297 pub message: String,
298 pub stack: Option<String>,
299 pub context: Option<serde_json::Value>,
300 pub correlation_id: Option<String>,
301 pub breadcrumbs: Option<Vec<Breadcrumb>>,
302 pub page_url: Option<String>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct Breadcrumb {
308 pub message: String,
309 #[serde(default)]
310 pub data: serde_json::Value,
311 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ClientContext {
317 pub page_url: Option<String>,
318 pub referrer: Option<String>,
319 pub session_id: Option<String>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SignalResponse {
325 pub ok: bool,
326 pub session_id: Option<Uuid>,
328}
329
330#[cfg(test)]
331#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
332mod tests {
333 use super::*;
334
335 #[tokio::test]
336 async fn signal_event_type_display_produces_snake_case() {
337 assert_eq!(SignalEventType::PageView.to_string(), "page_view");
338 assert_eq!(SignalEventType::RpcCall.to_string(), "rpc_call");
339 assert_eq!(SignalEventType::Track.to_string(), "track");
340 assert_eq!(SignalEventType::SessionStart.to_string(), "session_start");
341 assert_eq!(SignalEventType::SessionEnd.to_string(), "session_end");
342 assert_eq!(SignalEventType::Error.to_string(), "error");
343 assert_eq!(SignalEventType::Breadcrumb.to_string(), "breadcrumb");
344 assert_eq!(
345 SignalEventType::ServerExecution.to_string(),
346 "server_execution"
347 );
348 }
349
350 #[tokio::test]
351 async fn signal_event_type_serde_round_trip() {
352 let variants = [
353 SignalEventType::PageView,
354 SignalEventType::RpcCall,
355 SignalEventType::Track,
356 SignalEventType::SessionStart,
357 SignalEventType::SessionEnd,
358 SignalEventType::Error,
359 SignalEventType::Breadcrumb,
360 SignalEventType::ServerExecution,
361 ];
362
363 for variant in variants {
364 let json = serde_json::to_string(&variant).unwrap();
365 let deserialized: SignalEventType = serde_json::from_str(&json).unwrap();
366 assert_eq!(variant, deserialized);
367 }
368 }
369
370 #[tokio::test]
371 async fn rpc_call_sets_correct_fields_on_success() {
372 let user_id = Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap();
373 let tenant_id = Uuid::parse_str("f1f2f3f4-a1a2-b1b2-c1c2-d1d2d3d4d5d6").unwrap();
374
375 let event = SignalEvent::rpc_call(
376 "get_users",
377 "query",
378 42,
379 true,
380 Some(user_id),
381 Some(tenant_id),
382 Some("corr-123".to_string()),
383 Some("127.0.0.1".to_string()),
384 Some("test-agent".to_string()),
385 Some("visitor-abc".to_string()),
386 false,
387 );
388
389 assert_eq!(event.event_type, SignalEventType::RpcCall);
390 assert_eq!(event.function_name.as_deref(), Some("get_users"));
391 assert_eq!(event.function_kind.as_deref(), Some("query"));
392 assert_eq!(event.duration_ms, Some(42));
393 assert_eq!(event.status.as_deref(), Some("success"));
394 assert!(event.device_type.is_none());
395 assert!(event.browser.is_none());
396 assert!(event.os.is_none());
397 assert!(event.session_id.is_none());
398 assert_eq!(
399 event.properties,
400 serde_json::Value::Object(serde_json::Map::new())
401 );
402 }
403
404 #[tokio::test]
405 async fn rpc_call_sets_error_status_when_not_success() {
406 let event = SignalEvent::rpc_call(
407 "create_user",
408 "mutation",
409 100,
410 false,
411 None,
412 None,
413 None,
414 None,
415 None,
416 None,
417 false,
418 );
419
420 assert_eq!(event.status.as_deref(), Some("error"));
421 }
422
423 #[tokio::test]
424 async fn client_event_deserializes_with_timestamp() {
425 let json = r#"{
426 "event": "click",
427 "properties": {"button": "submit"},
428 "correlation_id": "abc",
429 "timestamp": "2025-01-15T10:30:00Z"
430 }"#;
431
432 let event: ClientEvent = serde_json::from_str(json).unwrap();
433 assert_eq!(event.event, "click");
434 assert!(event.timestamp.is_some());
435 assert_eq!(event.correlation_id.as_deref(), Some("abc"));
436 }
437
438 #[tokio::test]
439 async fn client_event_deserializes_without_timestamp() {
440 let json = r#"{
441 "event": "click"
442 }"#;
443
444 let event: ClientEvent = serde_json::from_str(json).unwrap();
445 assert_eq!(event.event, "click");
446 assert!(event.timestamp.is_none());
447 assert_eq!(event.properties, serde_json::Value::Null);
448 }
449
450 #[tokio::test]
451 async fn page_view_payload_deserializes_with_all_utm_fields() {
452 let json = r#"{
453 "url": "https://example.com/page",
454 "referrer": "https://google.com",
455 "title": "Home",
456 "utm_source": "google",
457 "utm_medium": "cpc",
458 "utm_campaign": "spring",
459 "utm_term": "rust framework",
460 "utm_content": "banner",
461 "correlation_id": "corr-456"
462 }"#;
463
464 let payload: PageViewPayload = serde_json::from_str(json).unwrap();
465 assert_eq!(payload.url, "https://example.com/page");
466 assert_eq!(payload.referrer.as_deref(), Some("https://google.com"));
467 assert_eq!(payload.title.as_deref(), Some("Home"));
468 assert_eq!(payload.utm_source.as_deref(), Some("google"));
469 assert_eq!(payload.utm_medium.as_deref(), Some("cpc"));
470 assert_eq!(payload.utm_campaign.as_deref(), Some("spring"));
471 assert_eq!(payload.utm_term.as_deref(), Some("rust framework"));
472 assert_eq!(payload.utm_content.as_deref(), Some("banner"));
473 }
474
475 #[tokio::test]
476 async fn page_view_payload_deserializes_with_only_url() {
477 let json = r#"{"url": "https://example.com"}"#;
478
479 let payload: PageViewPayload = serde_json::from_str(json).unwrap();
480 assert_eq!(payload.url, "https://example.com");
481 assert!(payload.referrer.is_none());
482 assert!(payload.title.is_none());
483 assert!(payload.utm_source.is_none());
484 assert!(payload.utm_medium.is_none());
485 assert!(payload.utm_campaign.is_none());
486 assert!(payload.utm_term.is_none());
487 assert!(payload.utm_content.is_none());
488 }
489
490 #[tokio::test]
491 async fn diagnostic_error_deserializes_with_breadcrumbs() {
492 let json = r#"{
493 "message": "TypeError: null is not an object",
494 "stack": "at foo.js:10",
495 "breadcrumbs": [
496 {"message": "clicked button", "data": {}, "timestamp": null},
497 {"message": "navigated to /settings", "data": {"from": "/home"}}
498 ]
499 }"#;
500
501 let error: DiagnosticError = serde_json::from_str(json).unwrap();
502 assert_eq!(error.message, "TypeError: null is not an object");
503 assert_eq!(error.stack.as_deref(), Some("at foo.js:10"));
504 let breadcrumbs = error.breadcrumbs.unwrap();
505 assert_eq!(breadcrumbs.len(), 2);
506 assert_eq!(breadcrumbs[0].message, "clicked button");
507 assert_eq!(breadcrumbs[1].message, "navigated to /settings");
508 }
509
510 #[tokio::test]
511 async fn diagnostic_error_deserializes_with_null_breadcrumbs() {
512 let json = r#"{
513 "message": "ReferenceError: x is not defined",
514 "stack": null,
515 "context": null,
516 "correlation_id": null,
517 "breadcrumbs": null,
518 "page_url": null
519 }"#;
520
521 let error: DiagnosticError = serde_json::from_str(json).unwrap();
522 assert_eq!(error.message, "ReferenceError: x is not defined");
523 assert!(error.breadcrumbs.is_none());
524 }
525
526 #[tokio::test]
527 async fn signal_response_serializes_with_session_id() {
528 let session_id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
529 let response = SignalResponse {
530 ok: true,
531 session_id: Some(session_id),
532 };
533
534 let json = serde_json::to_string(&response).unwrap();
535 assert!(json.contains("\"ok\":true"));
536 assert!(json.contains("\"session_id\":\"11111111-2222-3333-4444-555555555555\""));
537 }
538
539 #[tokio::test]
540 async fn signal_response_serializes_not_ok_with_no_session() {
541 let response = SignalResponse {
542 ok: false,
543 session_id: None,
544 };
545
546 let json = serde_json::to_string(&response).unwrap();
547 assert!(json.contains("\"ok\":false"));
548 assert!(json.contains("\"session_id\":null"));
549 }
550
551 #[tokio::test]
552 async fn client_context_deserializes_with_all_fields_none() {
553 let json = r#"{
554 "page_url": null,
555 "referrer": null,
556 "session_id": null
557 }"#;
558
559 let ctx: ClientContext = serde_json::from_str(json).unwrap();
560 assert!(ctx.page_url.is_none());
561 assert!(ctx.referrer.is_none());
562 assert!(ctx.session_id.is_none());
563 }
564
565 #[tokio::test]
566 async fn signal_payload_deserializes_event_variant() {
567 let json = r#"{
568 "type": "event",
569 "payload": {
570 "events": [{"event": "click"}],
571 "context": null
572 }
573 }"#;
574
575 let payload: SignalPayload = serde_json::from_str(json).unwrap();
576 assert!(matches!(payload, SignalPayload::Event(_)));
577 }
578
579 #[tokio::test]
580 async fn signal_payload_deserializes_view_variant() {
581 let json = r#"{
582 "type": "view",
583 "payload": {
584 "url": "https://example.com/page"
585 }
586 }"#;
587
588 let payload: SignalPayload = serde_json::from_str(json).unwrap();
589 assert!(matches!(payload, SignalPayload::View(_)));
590 }
591
592 #[tokio::test]
593 async fn signal_payload_deserializes_report_variant() {
594 let json = r#"{
595 "type": "report",
596 "payload": {
597 "errors": [{"message": "boom"}]
598 }
599 }"#;
600
601 let payload: SignalPayload = serde_json::from_str(json).unwrap();
602 assert!(matches!(payload, SignalPayload::Report(_)));
603 }
604
605 #[tokio::test]
606 async fn server_execution_marks_status_and_clears_client_context() {
607 let event = SignalEvent::server_execution("send_email", "job", 1500, true, None);
611
612 assert_eq!(event.event_type, SignalEventType::ServerExecution);
613 assert_eq!(event.event_name.as_deref(), Some("send_email"));
614 assert_eq!(event.function_name.as_deref(), Some("send_email"));
615 assert_eq!(event.function_kind.as_deref(), Some("job"));
616 assert_eq!(event.duration_ms, Some(1500));
617 assert_eq!(event.status.as_deref(), Some("success"));
618 assert!(event.error_message.is_none());
619
620 assert!(event.client_ip.is_none());
622 assert!(event.user_agent.is_none());
623 assert!(event.visitor_id.is_none());
624 assert!(event.session_id.is_none());
625 assert!(event.user_id.is_none());
626 assert!(event.tenant_id.is_none());
627 assert!(event.correlation_id.is_none());
628 assert!(!event.is_bot, "background runs are never flagged as bots");
629 assert_eq!(
630 event.properties,
631 serde_json::Value::Object(serde_json::Map::new())
632 );
633 }
634
635 #[tokio::test]
636 async fn server_execution_records_error_message_and_failure_status() {
637 let event = SignalEvent::server_execution(
638 "process_payment",
639 "workflow",
640 8000,
641 false,
642 Some("connection refused".to_string()),
643 );
644
645 assert_eq!(event.status.as_deref(), Some("error"));
646 assert_eq!(event.error_message.as_deref(), Some("connection refused"));
647 assert!(event.error_stack.is_none());
650 assert!(event.error_context.is_none());
651 }
652
653 #[tokio::test]
654 async fn diagnostic_event_uses_track_type_and_threads_request_context() {
655 let user_id = Uuid::new_v4();
659 let props = serde_json::json!({"reason": "invalid_token"});
660
661 let event = SignalEvent::diagnostic(
662 "auth_failure",
663 props.clone(),
664 Some("10.0.0.5".to_string()),
665 Some("curl/8".to_string()),
666 Some("visitor-xyz".to_string()),
667 Some(user_id),
668 true,
669 );
670
671 assert_eq!(event.event_type, SignalEventType::Track);
672 assert_eq!(event.event_name.as_deref(), Some("auth_failure"));
673 assert_eq!(event.properties, props);
674 assert_eq!(event.client_ip.as_deref(), Some("10.0.0.5"));
675 assert_eq!(event.user_agent.as_deref(), Some("curl/8"));
676 assert_eq!(event.visitor_id.as_deref(), Some("visitor-xyz"));
677 assert_eq!(event.user_id, Some(user_id));
678 assert!(event.is_bot, "bot flag must round-trip");
679
680 assert!(event.function_name.is_none());
682 assert!(event.function_kind.is_none());
683 assert!(event.duration_ms.is_none());
684 assert!(event.status.is_none());
685 }
686
687 #[tokio::test]
688 async fn diagnostic_event_tolerates_all_optional_context_missing() {
689 let event = SignalEvent::diagnostic(
693 "rate_limit_exceeded",
694 serde_json::Value::Null,
695 None,
696 None,
697 None,
698 None,
699 false,
700 );
701
702 assert_eq!(event.event_type, SignalEventType::Track);
703 assert_eq!(event.event_name.as_deref(), Some("rate_limit_exceeded"));
704 assert_eq!(event.properties, serde_json::Value::Null);
705 assert!(event.client_ip.is_none());
706 assert!(event.user_agent.is_none());
707 assert!(!event.is_bot);
708 }
709
710 #[tokio::test]
711 async fn breadcrumb_data_defaults_to_null_when_absent() {
712 let json = r#"{"message": "form submitted"}"#;
716 let bc: Breadcrumb = serde_json::from_str(json).unwrap();
717 assert_eq!(bc.message, "form submitted");
718 assert_eq!(bc.data, serde_json::Value::Null);
719 assert!(bc.timestamp.is_none());
720 }
721
722 #[tokio::test]
723 async fn utm_params_default_is_all_none() {
724 let u = UtmParams::default();
727 assert!(u.source.is_none());
728 assert!(u.medium.is_none());
729 assert!(u.campaign.is_none());
730 assert!(u.term.is_none());
731 assert!(u.content.is_none());
732 }
733
734 #[tokio::test]
735 async fn signal_payload_unknown_type_fails_deserialization() {
736 let json = r#"{"type": "bogus", "payload": {}}"#;
739 let err = serde_json::from_str::<SignalPayload>(json).unwrap_err();
740 assert!(err.to_string().contains("bogus"), "got: {err}");
742 }
743
744 #[tokio::test]
745 async fn signal_event_type_serializes_to_snake_case_json_string() {
746 let j = serde_json::to_string(&SignalEventType::ServerExecution).unwrap();
748 assert_eq!(j, "\"server_execution\"");
749 let j = serde_json::to_string(&SignalEventType::PageView).unwrap();
750 assert_eq!(j, "\"page_view\"");
751 }
752
753 #[tokio::test]
754 async fn signal_payload_round_trips_all_variants() {
755 let event_payload = SignalPayload::Event(SignalEventBatch {
756 events: vec![ClientEvent {
757 event: "test".to_string(),
758 properties: serde_json::Value::Null,
759 correlation_id: None,
760 timestamp: None,
761 }],
762 context: None,
763 });
764 let json = serde_json::to_string(&event_payload).unwrap();
765 let deserialized: SignalPayload = serde_json::from_str(&json).unwrap();
766 assert!(matches!(deserialized, SignalPayload::Event(_)));
767
768 let view_payload = SignalPayload::View(PageViewPayload {
769 url: "https://example.com".to_string(),
770 referrer: None,
771 title: None,
772 utm_source: None,
773 utm_medium: None,
774 utm_campaign: None,
775 utm_term: None,
776 utm_content: None,
777 correlation_id: None,
778 });
779 let json = serde_json::to_string(&view_payload).unwrap();
780 let deserialized: SignalPayload = serde_json::from_str(&json).unwrap();
781 assert!(matches!(deserialized, SignalPayload::View(_)));
782
783 let report_payload = SignalPayload::Report(DiagnosticReport {
784 errors: vec![DiagnosticError {
785 message: "test error".to_string(),
786 stack: None,
787 context: None,
788 correlation_id: None,
789 breadcrumbs: None,
790 page_url: None,
791 }],
792 });
793 let json = serde_json::to_string(&report_payload).unwrap();
794 let deserialized: SignalPayload = serde_json::from_str(&json).unwrap();
795 assert!(matches!(deserialized, SignalPayload::Report(_)));
796 }
797}