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")]
12pub enum SignalEventType {
13    /// Page or screen view.
14    PageView,
15    /// Auto-captured backend RPC execution.
16    RpcCall,
17    /// Custom event from user code.
18    Track,
19    /// User identification (links anonymous activity to a user).
20    Identify,
21    /// Session started.
22    SessionStart,
23    /// Session ended (timeout or explicit close).
24    SessionEnd,
25    /// Frontend error or unhandled rejection.
26    Error,
27    /// Diagnostic breadcrumb for error reproduction.
28    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/// A single signal event ready for collection.
47#[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    // Page context
59    pub page_url: Option<String>,
60    pub referrer: Option<String>,
61
62    // RPC fields (denormalized for query performance)
63    pub function_name: Option<String>,
64    pub function_kind: Option<String>,
65    pub duration_ms: Option<i32>,
66    pub status: Option<String>,
67
68    // Diagnostics
69    pub error_message: Option<String>,
70    pub error_stack: Option<String>,
71    pub error_context: Option<serde_json::Value>,
72
73    // Client context
74    pub client_ip: Option<String>,
75    pub user_agent: Option<String>,
76
77    // Device classification (parsed from user_agent + platform header)
78    pub device_type: Option<String>,
79    pub browser: Option<String>,
80    pub os: Option<String>,
81
82    // Acquisition
83    pub utm: Option<UtmParams>,
84
85    // Classification
86    pub is_bot: bool,
87
88    pub timestamp: chrono::DateTime<chrono::Utc>,
89}
90
91impl SignalEvent {
92    /// Create an RPC call event from function execution metadata.
93    #[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/// UTM campaign parameters for acquisition tracking.
139#[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/// Batch of events sent from client trackers.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SignalEventBatch {
151    pub events: Vec<ClientEvent>,
152    pub context: Option<ClientContext>,
153}
154
155/// A single event from the client tracker.
156#[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/// Page view event from client.
166#[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/// User identification payload.
180#[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/// Diagnostic error report from client.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct DiagnosticReport {
190    pub errors: Vec<DiagnosticError>,
191}
192
193/// A single frontend error for diagnostics.
194#[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/// User action breadcrumb for error reproduction.
205#[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/// Shared client context sent alongside event batches.
214#[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/// Response from signal ingestion endpoints.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SignalResponse {
224    pub ok: bool,
225    /// Server-assigned session ID (returned on first event).
226    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}