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    /// Web Vitals performance metric (LCP, CLS, INP, FCP, TTFB, etc.).
30    WebVital,
31    /// Background execution (job, cron, workflow step, webhook, daemon tick).
32    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/// A single signal event ready for collection.
53#[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    // Page context
65    pub page_url: Option<String>,
66    pub referrer: Option<String>,
67
68    // RPC fields (denormalized for query performance)
69    pub function_name: Option<String>,
70    pub function_kind: Option<String>,
71    pub duration_ms: Option<i32>,
72    pub status: Option<String>,
73
74    // Diagnostics
75    pub error_message: Option<String>,
76    pub error_stack: Option<String>,
77    pub error_context: Option<serde_json::Value>,
78
79    // Client context
80    pub client_ip: Option<String>,
81    pub country: Option<String>,
82    pub city: Option<String>,
83    pub user_agent: Option<String>,
84
85    // Device classification (parsed from user_agent + platform header)
86    pub device_type: Option<String>,
87    pub browser: Option<String>,
88    pub os: Option<String>,
89
90    // Acquisition
91    pub utm: Option<UtmParams>,
92
93    // Classification
94    pub is_bot: bool,
95
96    pub timestamp: chrono::DateTime<chrono::Utc>,
97}
98
99impl SignalEvent {
100    /// Create a server-initiated execution event (job, cron, workflow step,
101    /// webhook, daemon tick). These runs have no client_ip/user_agent/visitor,
102    /// only a function kind + name + outcome.
103    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    /// Create a diagnostic/audit event (auth failure, rate limit hit, etc.)
143    /// with context from the request.
144    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    /// Create an RPC call event from function execution metadata.
185    #[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/// UTM campaign parameters for acquisition tracking.
233#[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/// Batch of events sent from client trackers.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct SignalEventBatch {
245    pub events: Vec<ClientEvent>,
246    pub context: Option<ClientContext>,
247}
248
249/// A single event from the client tracker.
250#[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/// Page view event from client.
260#[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/// User identification payload.
274#[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/// Diagnostic error report from client.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct DiagnosticReport {
284    pub errors: Vec<DiagnosticError>,
285}
286
287/// A single frontend error for diagnostics.
288#[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/// User action breadcrumb for error reproduction.
299#[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/// Shared client context sent alongside event batches.
308#[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/// Batch of Web Vitals / navigation metrics from a client tracker.
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct WebVitalBatch {
318    pub vitals: Vec<WebVitalEntry>,
319    pub context: Option<ClientContext>,
320}
321
322/// A single Web Vitals sample (LCP, CLS, INP, FCP, TTFB, navigation, etc.).
323///
324/// `name` is the metric name (e.g. "lcp", "cls", "inp", "fcp", "ttfb",
325/// "navigation", "long_task"). `value` is the numeric measurement (ms for
326/// timings, unitless for CLS, etc.). Optional fields carry diagnostic
327/// detail (rating, navigation type, attribution).
328#[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/// Response from signal ingestion endpoints.
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct SignalResponse {
347    pub ok: bool,
348    /// Server-assigned session ID (returned on first event).
349    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}