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 WebVital,
31 ServerExecution,
33}
34
35impl std::fmt::Display for SignalEventType {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::PageView => write!(f, "page_view"),
39 Self::RpcCall => write!(f, "rpc_call"),
40 Self::Track => write!(f, "track"),
41 Self::Identify => write!(f, "identify"),
42 Self::SessionStart => write!(f, "session_start"),
43 Self::SessionEnd => write!(f, "session_end"),
44 Self::Error => write!(f, "error"),
45 Self::Breadcrumb => write!(f, "breadcrumb"),
46 Self::WebVital => write!(f, "web_vital"),
47 Self::ServerExecution => write!(f, "server_execution"),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SignalEvent {
55 pub event_type: SignalEventType,
56 pub event_name: Option<String>,
57 pub correlation_id: Option<String>,
58 pub session_id: Option<Uuid>,
59 pub visitor_id: Option<String>,
60 pub user_id: Option<Uuid>,
61 pub tenant_id: Option<Uuid>,
62 pub properties: serde_json::Value,
63
64 pub page_url: Option<String>,
66 pub referrer: Option<String>,
67
68 pub function_name: Option<String>,
70 pub function_kind: Option<String>,
71 pub duration_ms: Option<i32>,
72 pub status: Option<String>,
73
74 pub error_message: Option<String>,
76 pub error_stack: Option<String>,
77 pub error_context: Option<serde_json::Value>,
78
79 pub client_ip: Option<String>,
81 pub country: Option<String>,
82 pub city: Option<String>,
83 pub user_agent: Option<String>,
84
85 pub device_type: Option<String>,
87 pub browser: Option<String>,
88 pub os: Option<String>,
89
90 pub utm: Option<UtmParams>,
92
93 pub is_bot: bool,
95
96 pub timestamp: chrono::DateTime<chrono::Utc>,
97}
98
99impl SignalEvent {
100 pub fn server_execution(
104 name: &str,
105 kind: &str,
106 duration_ms: i32,
107 success: bool,
108 error_message: Option<String>,
109 ) -> Self {
110 let n = name.to_string();
111 Self {
112 event_type: SignalEventType::ServerExecution,
113 event_name: Some(n.clone()),
114 correlation_id: None,
115 session_id: None,
116 visitor_id: None,
117 user_id: None,
118 tenant_id: None,
119 properties: serde_json::Value::Object(serde_json::Map::new()),
120 page_url: None,
121 referrer: None,
122 function_name: Some(n),
123 function_kind: Some(kind.to_string()),
124 duration_ms: Some(duration_ms),
125 status: Some(if success { "success" } else { "error" }.to_string()),
126 error_message,
127 error_stack: None,
128 error_context: None,
129 client_ip: None,
130 country: None,
131 city: None,
132 user_agent: None,
133 device_type: None,
134 browser: None,
135 os: None,
136 utm: None,
137 is_bot: false,
138 timestamp: chrono::Utc::now(),
139 }
140 }
141
142 pub fn diagnostic(
145 event_name: &str,
146 properties: serde_json::Value,
147 client_ip: Option<String>,
148 user_agent: Option<String>,
149 visitor_id: Option<String>,
150 user_id: Option<Uuid>,
151 is_bot: bool,
152 ) -> Self {
153 Self {
154 event_type: SignalEventType::Track,
155 event_name: Some(event_name.to_string()),
156 correlation_id: None,
157 session_id: None,
158 visitor_id,
159 user_id,
160 tenant_id: None,
161 properties,
162 page_url: None,
163 referrer: None,
164 function_name: None,
165 function_kind: None,
166 duration_ms: None,
167 status: None,
168 error_message: None,
169 error_stack: None,
170 error_context: None,
171 client_ip,
172 country: None,
173 city: None,
174 user_agent,
175 device_type: None,
176 browser: None,
177 os: None,
178 utm: None,
179 is_bot,
180 timestamp: chrono::Utc::now(),
181 }
182 }
183
184 #[allow(clippy::too_many_arguments)]
186 pub fn rpc_call(
187 function_name: &str,
188 function_kind: &str,
189 duration_ms: i32,
190 success: bool,
191 user_id: Option<Uuid>,
192 tenant_id: Option<Uuid>,
193 correlation_id: Option<String>,
194 client_ip: Option<String>,
195 user_agent: Option<String>,
196 visitor_id: Option<String>,
197 is_bot: bool,
198 ) -> Self {
199 let name = function_name.to_string();
200 Self {
201 event_type: SignalEventType::RpcCall,
202 event_name: Some(name.clone()),
203 correlation_id,
204 session_id: None,
205 visitor_id,
206 user_id,
207 tenant_id,
208 properties: serde_json::Value::Object(serde_json::Map::new()),
209 page_url: None,
210 referrer: None,
211 function_name: Some(name),
212 function_kind: Some(function_kind.to_string()),
213 duration_ms: Some(duration_ms),
214 status: Some(if success { "success" } else { "error" }.to_string()),
215 error_message: None,
216 error_stack: None,
217 error_context: None,
218 client_ip,
219 country: None,
220 city: None,
221 user_agent,
222 device_type: None,
223 browser: None,
224 os: None,
225 utm: None,
226 is_bot,
227 timestamp: chrono::Utc::now(),
228 }
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct UtmParams {
235 pub source: Option<String>,
236 pub medium: Option<String>,
237 pub campaign: Option<String>,
238 pub term: Option<String>,
239 pub content: Option<String>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct SignalEventBatch {
245 pub events: Vec<ClientEvent>,
246 pub context: Option<ClientContext>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct ClientEvent {
252 pub event: String,
253 #[serde(default)]
254 pub properties: serde_json::Value,
255 pub correlation_id: Option<String>,
256 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct PageViewPayload {
262 pub url: String,
263 pub referrer: Option<String>,
264 pub title: Option<String>,
265 pub utm_source: Option<String>,
266 pub utm_medium: Option<String>,
267 pub utm_campaign: Option<String>,
268 pub utm_term: Option<String>,
269 pub utm_content: Option<String>,
270 pub correlation_id: Option<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct IdentifyPayload {
276 pub user_id: String,
277 #[serde(default)]
278 pub traits: serde_json::Value,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct DiagnosticReport {
284 pub errors: Vec<DiagnosticError>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct DiagnosticError {
290 pub message: String,
291 pub stack: Option<String>,
292 pub context: Option<serde_json::Value>,
293 pub correlation_id: Option<String>,
294 pub breadcrumbs: Option<Vec<Breadcrumb>>,
295 pub page_url: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct Breadcrumb {
301 pub message: String,
302 #[serde(default)]
303 pub data: serde_json::Value,
304 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct ClientContext {
310 pub page_url: Option<String>,
311 pub referrer: Option<String>,
312 pub session_id: Option<String>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct WebVitalBatch {
318 pub vitals: Vec<WebVitalEntry>,
319 pub context: Option<ClientContext>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct WebVitalEntry {
330 pub name: String,
331 pub value: f64,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub rating: Option<String>,
334 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
335 pub attribution: serde_json::Value,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub correlation_id: Option<String>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub page_url: Option<String>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct SignalResponse {
347 pub ok: bool,
348 pub session_id: Option<Uuid>,
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
354mod tests {
355 use super::*;
356
357 #[tokio::test]
358 async fn signal_event_type_display_produces_snake_case() {
359 assert_eq!(SignalEventType::PageView.to_string(), "page_view");
360 assert_eq!(SignalEventType::RpcCall.to_string(), "rpc_call");
361 assert_eq!(SignalEventType::Track.to_string(), "track");
362 assert_eq!(SignalEventType::Identify.to_string(), "identify");
363 assert_eq!(SignalEventType::SessionStart.to_string(), "session_start");
364 assert_eq!(SignalEventType::SessionEnd.to_string(), "session_end");
365 assert_eq!(SignalEventType::Error.to_string(), "error");
366 assert_eq!(SignalEventType::Breadcrumb.to_string(), "breadcrumb");
367 assert_eq!(SignalEventType::WebVital.to_string(), "web_vital");
368 assert_eq!(
369 SignalEventType::ServerExecution.to_string(),
370 "server_execution"
371 );
372 }
373
374 #[tokio::test]
375 async fn signal_event_type_serde_round_trip() {
376 let variants = [
377 SignalEventType::PageView,
378 SignalEventType::RpcCall,
379 SignalEventType::Track,
380 SignalEventType::Identify,
381 SignalEventType::SessionStart,
382 SignalEventType::SessionEnd,
383 SignalEventType::Error,
384 SignalEventType::Breadcrumb,
385 SignalEventType::WebVital,
386 SignalEventType::ServerExecution,
387 ];
388
389 for variant in variants {
390 let json = serde_json::to_string(&variant).unwrap();
391 let deserialized: SignalEventType = serde_json::from_str(&json).unwrap();
392 assert_eq!(variant, deserialized);
393 }
394 }
395
396 #[tokio::test]
397 async fn rpc_call_sets_correct_fields_on_success() {
398 let user_id = Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap();
399 let tenant_id = Uuid::parse_str("f1f2f3f4-a1a2-b1b2-c1c2-d1d2d3d4d5d6").unwrap();
400
401 let event = SignalEvent::rpc_call(
402 "get_users",
403 "query",
404 42,
405 true,
406 Some(user_id),
407 Some(tenant_id),
408 Some("corr-123".to_string()),
409 Some("127.0.0.1".to_string()),
410 Some("test-agent".to_string()),
411 Some("visitor-abc".to_string()),
412 false,
413 );
414
415 assert_eq!(event.event_type, SignalEventType::RpcCall);
416 assert_eq!(event.function_name.as_deref(), Some("get_users"));
417 assert_eq!(event.function_kind.as_deref(), Some("query"));
418 assert_eq!(event.duration_ms, Some(42));
419 assert_eq!(event.status.as_deref(), Some("success"));
420 assert!(event.device_type.is_none());
421 assert!(event.browser.is_none());
422 assert!(event.os.is_none());
423 assert!(event.session_id.is_none());
424 assert_eq!(
425 event.properties,
426 serde_json::Value::Object(serde_json::Map::new())
427 );
428 }
429
430 #[tokio::test]
431 async fn rpc_call_sets_error_status_when_not_success() {
432 let event = SignalEvent::rpc_call(
433 "create_user",
434 "mutation",
435 100,
436 false,
437 None,
438 None,
439 None,
440 None,
441 None,
442 None,
443 false,
444 );
445
446 assert_eq!(event.status.as_deref(), Some("error"));
447 }
448
449 #[tokio::test]
450 async fn client_event_deserializes_with_timestamp() {
451 let json = r#"{
452 "event": "click",
453 "properties": {"button": "submit"},
454 "correlation_id": "abc",
455 "timestamp": "2025-01-15T10:30:00Z"
456 }"#;
457
458 let event: ClientEvent = serde_json::from_str(json).unwrap();
459 assert_eq!(event.event, "click");
460 assert!(event.timestamp.is_some());
461 assert_eq!(event.correlation_id.as_deref(), Some("abc"));
462 }
463
464 #[tokio::test]
465 async fn client_event_deserializes_without_timestamp() {
466 let json = r#"{
467 "event": "click"
468 }"#;
469
470 let event: ClientEvent = serde_json::from_str(json).unwrap();
471 assert_eq!(event.event, "click");
472 assert!(event.timestamp.is_none());
473 assert_eq!(event.properties, serde_json::Value::Null);
474 }
475
476 #[tokio::test]
477 async fn page_view_payload_deserializes_with_all_utm_fields() {
478 let json = r#"{
479 "url": "https://example.com/page",
480 "referrer": "https://google.com",
481 "title": "Home",
482 "utm_source": "google",
483 "utm_medium": "cpc",
484 "utm_campaign": "spring",
485 "utm_term": "rust framework",
486 "utm_content": "banner",
487 "correlation_id": "corr-456"
488 }"#;
489
490 let payload: PageViewPayload = serde_json::from_str(json).unwrap();
491 assert_eq!(payload.url, "https://example.com/page");
492 assert_eq!(payload.referrer.as_deref(), Some("https://google.com"));
493 assert_eq!(payload.title.as_deref(), Some("Home"));
494 assert_eq!(payload.utm_source.as_deref(), Some("google"));
495 assert_eq!(payload.utm_medium.as_deref(), Some("cpc"));
496 assert_eq!(payload.utm_campaign.as_deref(), Some("spring"));
497 assert_eq!(payload.utm_term.as_deref(), Some("rust framework"));
498 assert_eq!(payload.utm_content.as_deref(), Some("banner"));
499 }
500
501 #[tokio::test]
502 async fn page_view_payload_deserializes_with_only_url() {
503 let json = r#"{"url": "https://example.com"}"#;
504
505 let payload: PageViewPayload = serde_json::from_str(json).unwrap();
506 assert_eq!(payload.url, "https://example.com");
507 assert!(payload.referrer.is_none());
508 assert!(payload.title.is_none());
509 assert!(payload.utm_source.is_none());
510 assert!(payload.utm_medium.is_none());
511 assert!(payload.utm_campaign.is_none());
512 assert!(payload.utm_term.is_none());
513 assert!(payload.utm_content.is_none());
514 }
515
516 #[tokio::test]
517 async fn diagnostic_error_deserializes_with_breadcrumbs() {
518 let json = r#"{
519 "message": "TypeError: null is not an object",
520 "stack": "at foo.js:10",
521 "breadcrumbs": [
522 {"message": "clicked button", "data": {}, "timestamp": null},
523 {"message": "navigated to /settings", "data": {"from": "/home"}}
524 ]
525 }"#;
526
527 let error: DiagnosticError = serde_json::from_str(json).unwrap();
528 assert_eq!(error.message, "TypeError: null is not an object");
529 assert_eq!(error.stack.as_deref(), Some("at foo.js:10"));
530 let breadcrumbs = error.breadcrumbs.unwrap();
531 assert_eq!(breadcrumbs.len(), 2);
532 assert_eq!(breadcrumbs[0].message, "clicked button");
533 assert_eq!(breadcrumbs[1].message, "navigated to /settings");
534 }
535
536 #[tokio::test]
537 async fn diagnostic_error_deserializes_with_null_breadcrumbs() {
538 let json = r#"{
539 "message": "ReferenceError: x is not defined",
540 "stack": null,
541 "context": null,
542 "correlation_id": null,
543 "breadcrumbs": null,
544 "page_url": null
545 }"#;
546
547 let error: DiagnosticError = serde_json::from_str(json).unwrap();
548 assert_eq!(error.message, "ReferenceError: x is not defined");
549 assert!(error.breadcrumbs.is_none());
550 }
551
552 #[tokio::test]
553 async fn signal_response_serializes_with_session_id() {
554 let session_id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
555 let response = SignalResponse {
556 ok: true,
557 session_id: Some(session_id),
558 };
559
560 let json = serde_json::to_string(&response).unwrap();
561 assert!(json.contains("\"ok\":true"));
562 assert!(json.contains("\"session_id\":\"11111111-2222-3333-4444-555555555555\""));
563 }
564
565 #[tokio::test]
566 async fn signal_response_serializes_not_ok_with_no_session() {
567 let response = SignalResponse {
568 ok: false,
569 session_id: None,
570 };
571
572 let json = serde_json::to_string(&response).unwrap();
573 assert!(json.contains("\"ok\":false"));
574 assert!(json.contains("\"session_id\":null"));
575 }
576
577 #[tokio::test]
578 async fn identify_payload_deserializes_with_traits() {
579 let json = r#"{
580 "user_id": "user-42",
581 "traits": {"plan": "pro", "team_size": 5}
582 }"#;
583
584 let payload: IdentifyPayload = serde_json::from_str(json).unwrap();
585 assert_eq!(payload.user_id, "user-42");
586 assert_eq!(payload.traits["plan"], "pro");
587 assert_eq!(payload.traits["team_size"], 5);
588 }
589
590 #[tokio::test]
591 async fn identify_payload_deserializes_without_traits() {
592 let json = r#"{"user_id": "user-99"}"#;
593
594 let payload: IdentifyPayload = serde_json::from_str(json).unwrap();
595 assert_eq!(payload.user_id, "user-99");
596 assert_eq!(payload.traits, serde_json::Value::Null);
597 }
598
599 #[tokio::test]
600 async fn client_context_deserializes_with_all_fields_none() {
601 let json = r#"{
602 "page_url": null,
603 "referrer": null,
604 "session_id": null
605 }"#;
606
607 let ctx: ClientContext = serde_json::from_str(json).unwrap();
608 assert!(ctx.page_url.is_none());
609 assert!(ctx.referrer.is_none());
610 assert!(ctx.session_id.is_none());
611 }
612}