Skip to main content

forge_core/
signals.rs

1//! Shared types for the signals pipeline (product analytics + diagnostics).
2//!
3//! These types are used by both `forge-runtime` (server-side collection) and
4//! client packages (event serialization).
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9/// Event types tracked by the signals pipeline.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12#[non_exhaustive]
13pub enum SignalEventType {
14    /// Page or screen view.
15    PageView,
16    /// Auto-captured backend RPC execution.
17    RpcCall,
18    /// Custom event from user code.
19    Track,
20    /// Session started.
21    SessionStart,
22    /// Session ended (timeout or explicit close).
23    SessionEnd,
24    /// Frontend error or unhandled rejection.
25    Error,
26    /// Diagnostic breadcrumb for error reproduction.
27    Breadcrumb,
28    /// Background execution (job, cron, workflow step, webhook, daemon tick).
29    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/// A single signal event ready for collection.
48#[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    // Page context
60    pub page_url: Option<String>,
61    pub referrer: Option<String>,
62
63    // RPC fields (denormalized for query performance)
64    pub function_name: Option<String>,
65    pub function_kind: Option<String>,
66    pub duration_ms: Option<i32>,
67    pub status: Option<String>,
68
69    // Diagnostics
70    pub error_message: Option<String>,
71    pub error_stack: Option<String>,
72    pub error_context: Option<serde_json::Value>,
73
74    // Client context
75    pub client_ip: Option<String>,
76    pub country: Option<String>,
77    pub city: Option<String>,
78    pub user_agent: Option<String>,
79
80    // Device classification (parsed from user_agent + platform header)
81    pub device_type: Option<String>,
82    pub browser: Option<String>,
83    pub os: Option<String>,
84
85    // Acquisition
86    pub utm: Option<UtmParams>,
87
88    // Classification
89    pub is_bot: bool,
90
91    pub timestamp: chrono::DateTime<chrono::Utc>,
92}
93
94impl SignalEvent {
95    /// Create a server-initiated execution event (job, cron, workflow step,
96    /// webhook, daemon tick). These runs have no client_ip/user_agent/visitor,
97    /// only a function kind + name + outcome.
98    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    /// Create a diagnostic/audit event (auth failure, rate limit hit, etc.)
138    /// with context from the request.
139    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    /// Create an RPC call event from function execution metadata.
180    #[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/// UTM campaign parameters for acquisition tracking.
228#[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/// Unified signal ingestion payload. Discriminated by `type` field.
238///
239/// Clients send a single `POST /_api/signal` with one of three subtypes:
240/// - `event`: batch of custom/tracked events
241/// - `view`: page view with UTM and referrer context
242/// - `report`: diagnostic error report (bypasses DNT)
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(tag = "type", content = "payload")]
245pub enum SignalPayload {
246    /// Batch of custom events from the client tracker.
247    #[serde(rename = "event")]
248    Event(SignalEventBatch),
249    /// Page view event with URL, referrer, and UTM parameters.
250    #[serde(rename = "view")]
251    View(PageViewPayload),
252    /// Diagnostic error report. Bypasses DNT/Sec-GPC checks.
253    #[serde(rename = "report")]
254    Report(DiagnosticReport),
255}
256
257/// Batch of events sent from client trackers.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct SignalEventBatch {
260    pub events: Vec<ClientEvent>,
261    pub context: Option<ClientContext>,
262}
263
264/// A single event from the client tracker.
265#[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/// Page view event from client.
275#[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/// Diagnostic error report from client.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct DiagnosticReport {
291    pub errors: Vec<DiagnosticError>,
292}
293
294/// A single frontend error for diagnostics.
295#[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/// User action breadcrumb for error reproduction.
306#[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/// Shared client context sent alongside event batches.
315#[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/// Response from signal ingestion endpoints.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SignalResponse {
325    pub ok: bool,
326    /// Server-assigned session ID (returned on first event).
327    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        // Server-initiated runs have no visitor/UA/IP/session — confirm the
608        // constructor leaves those slots empty so the events table doesn't
609        // accidentally attribute background work to a request.
610        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        // Must NOT carry any client/session attribution.
621        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        // error_stack/context are reserved for frontend reports — server
648        // executions only get a message.
649        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        // Diagnostics (auth failure, rate limit hit, etc.) ride the Track
656        // event_type so the analytics table doesn't gain another partition;
657        // they MUST carry the request attribution the caller provides.
658        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        // Diagnostics aren't RPC calls — function fields stay empty.
681        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        // Anonymous diagnostic (no IP/UA/visitor/user) must still produce a
690        // valid event — the worker paths that emit these often run in
691        // contexts with no request at all.
692        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        // Frontend trackers commonly omit `data` for trivial breadcrumbs;
713        // the #[serde(default)] must turn that into Value::Null rather than
714        // failing deserialization.
715        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        // The Default impl is load-bearing — RPC handlers construct
725        // UtmParams::default() when the URL has no utm_* keys.
726        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        // Pin the discriminator behavior: an unknown `type` MUST be rejected
737        // so the ingestion handler doesn't silently drop misrouted payloads.
738        let json = r#"{"type": "bogus", "payload": {}}"#;
739        let err = serde_json::from_str::<SignalPayload>(json).unwrap_err();
740        // serde_json's wording around unknown variants includes the bad tag.
741        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        // Display covers the human format; this pins the wire format.
747        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}