firebase_rs_sdk/analytics/
api.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, LazyLock, Mutex};
5
6use async_trait::async_trait;
7
8use crate::analytics::config::{fetch_dynamic_config, from_app_options, DynamicConfig};
9use crate::analytics::constants::ANALYTICS_COMPONENT_NAME;
10use crate::analytics::error::{internal_error, invalid_argument, AnalyticsResult};
11use crate::analytics::gtag::{GlobalGtagRegistry, GtagState};
12use crate::analytics::transport::{
13    MeasurementProtocolConfig, MeasurementProtocolDispatcher, MeasurementProtocolEndpoint,
14};
15use crate::app;
16use crate::app::FirebaseApp;
17use crate::component::types::{
18    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
19};
20use crate::component::{Component, ComponentType};
21
22#[derive(Clone)]
23pub struct Analytics {
24    inner: Arc<AnalyticsInner>,
25}
26
27impl fmt::Debug for Analytics {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.debug_struct("Analytics")
30            .field("app", &self.inner.app.name())
31            .finish()
32    }
33}
34
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct AnalyticsSettings {
37    pub config: BTreeMap<String, String>,
38    pub send_page_view: Option<bool>,
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct ConsentSettings {
43    pub entries: BTreeMap<String, String>,
44}
45
46struct AnalyticsInner {
47    app: FirebaseApp,
48    events: Mutex<Vec<AnalyticsEvent>>,
49    client_id: Mutex<String>,
50    transport: Mutex<Option<Arc<dyn AnalyticsTransport>>>,
51    config: Mutex<Option<DynamicConfig>>,
52    default_event_params: Mutex<BTreeMap<String, String>>,
53    consent_settings: Mutex<Option<ConsentSettings>>,
54    analytics_settings: Mutex<AnalyticsSettings>,
55    collection_enabled: AtomicBool,
56    gtag: GlobalGtagRegistry,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct AnalyticsEvent {
61    pub name: String,
62    pub params: BTreeMap<String, String>,
63}
64
65#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
66#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
67trait AnalyticsTransport: Send + Sync {
68    async fn send(
69        &self,
70        client_id: &str,
71        event_name: &str,
72        params: &BTreeMap<String, String>,
73    ) -> AnalyticsResult<()>;
74}
75
76#[derive(Clone)]
77struct MeasurementProtocolTransport {
78    dispatcher: MeasurementProtocolDispatcher,
79}
80
81impl MeasurementProtocolTransport {
82    fn new(dispatcher: MeasurementProtocolDispatcher) -> Self {
83        Self { dispatcher }
84    }
85}
86
87#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
88#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
89impl AnalyticsTransport for MeasurementProtocolTransport {
90    async fn send(
91        &self,
92        client_id: &str,
93        event_name: &str,
94        params: &BTreeMap<String, String>,
95    ) -> AnalyticsResult<()> {
96        self.dispatcher
97            .send_event(client_id, event_name, params)
98            .await
99    }
100}
101
102impl Analytics {
103    fn new(app: FirebaseApp) -> Self {
104        let gtag = GlobalGtagRegistry::shared();
105        gtag.inner().set_data_layer_name("dataLayer");
106
107        let inner = AnalyticsInner {
108            app,
109            events: Mutex::new(Vec::new()),
110            client_id: Mutex::new(generate_client_id()),
111            transport: Mutex::new(None),
112            config: Mutex::new(None),
113            default_event_params: Mutex::new(BTreeMap::new()),
114            consent_settings: Mutex::new(None),
115            analytics_settings: Mutex::new(AnalyticsSettings::default()),
116            collection_enabled: AtomicBool::new(true),
117            gtag,
118        };
119        Self {
120            inner: Arc::new(inner),
121        }
122    }
123
124    pub fn app(&self) -> &FirebaseApp {
125        &self.inner.app
126    }
127
128    pub async fn log_event(
129        &self,
130        name: &str,
131        params: BTreeMap<String, String>,
132    ) -> AnalyticsResult<()> {
133        validate_event_name(name)?;
134        let merged_params = self.merge_default_event_params(params);
135        let mut events = self.inner.events.lock().unwrap();
136        let event = AnalyticsEvent {
137            name: name.to_string(),
138            params: merged_params,
139        };
140        events.push(event.clone());
141        drop(events);
142
143        self.dispatch_event(&event).await
144    }
145
146    pub fn recorded_events(&self) -> Vec<AnalyticsEvent> {
147        self.inner.events.lock().unwrap().clone()
148    }
149
150    /// Returns a snapshot of the gtag bootstrap state collected so far.
151    pub fn gtag_state(&self) -> GtagState {
152        self.inner.gtag.inner().snapshot()
153    }
154
155    /// Resolves the measurement configuration for this analytics instance. The value is derived
156    /// from the Firebase app options when possible and otherwise fetched from the Firebase
157    /// analytics REST endpoint. Results are cached for subsequent calls.
158    pub async fn measurement_config(&self) -> AnalyticsResult<DynamicConfig> {
159        self.ensure_dynamic_config().await
160    }
161
162    /// Configures the analytics instance to forward events using the GA4 Measurement Protocol.
163    ///
164    /// The configuration requires a valid measurement ID and API secret generated from the
165    /// associated Google Analytics property. If a dispatcher has already been configured it is
166    /// replaced.
167    pub fn configure_measurement_protocol(
168        &self,
169        config: MeasurementProtocolConfig,
170    ) -> AnalyticsResult<()> {
171        let dispatcher = MeasurementProtocolDispatcher::new(config)?;
172        let mut transport = self.inner.transport.lock().unwrap();
173        *transport = Some(Arc::new(MeasurementProtocolTransport::new(dispatcher)));
174        Ok(())
175    }
176
177    /// Convenience helper that resolves the measurement configuration and configures the
178    /// measurement protocol using the provided API secret. The dispatcher targets the default GA4
179    /// collection endpoint.
180    pub async fn configure_measurement_protocol_with_secret(
181        &self,
182        api_secret: impl Into<String>,
183    ) -> AnalyticsResult<()> {
184        self.configure_measurement_protocol_with_secret_internal(api_secret, None)
185            .await
186    }
187
188    /// Convenience helper that resolves the measurement configuration and configures the
189    /// measurement protocol using the provided API secret and custom endpoint. This is primarily
190    /// intended for testing or emulator scenarios.
191    pub async fn configure_measurement_protocol_with_secret_and_endpoint(
192        &self,
193        api_secret: impl Into<String>,
194        endpoint: MeasurementProtocolEndpoint,
195    ) -> AnalyticsResult<()> {
196        self.configure_measurement_protocol_with_secret_internal(api_secret, Some(endpoint))
197            .await
198    }
199
200    /// Overrides the client identifier reported to the measurement protocol. When unset the
201    /// analytics instance falls back to a randomly generated identifier created during
202    /// initialization.
203    pub fn set_client_id(&self, client_id: impl Into<String>) {
204        *self.inner.client_id.lock().unwrap() = client_id.into();
205    }
206
207    /// Sets the default event parameters that should be merged into every logged event unless
208    /// explicitly overridden.
209    pub fn set_default_event_parameters(&self, params: BTreeMap<String, String>) {
210        *self.inner.default_event_params.lock().unwrap() = params.clone();
211        self.inner.gtag.inner().set_default_event_parameters(params);
212    }
213
214    /// Configures default consent settings that mirror the GA4 consent API. The values are cached
215    /// so they can be applied once full gtag integration is implemented. Calling this replaces any
216    /// previously stored consent state.
217    pub fn set_consent_defaults(&self, consent: ConsentSettings) {
218        let entries = consent.entries.clone();
219        *self.inner.consent_settings.lock().unwrap() = Some(consent);
220        self.inner.gtag.inner().set_consent_defaults(Some(entries));
221    }
222
223    /// Applies analytics configuration options analogous to the JS `AnalyticsSettings` structure.
224    /// The configuration is cached and merged with any previously supplied settings.
225    pub fn apply_settings(&self, settings: AnalyticsSettings) {
226        let mut guard = self.inner.analytics_settings.lock().unwrap();
227        for (key, value) in settings.config {
228            guard.config.insert(key, value);
229        }
230        if settings.send_page_view.is_some() {
231            guard.send_page_view = settings.send_page_view;
232        }
233        self.inner.gtag.inner().set_config(guard.config.clone());
234        self.inner
235            .gtag
236            .inner()
237            .set_send_page_view(guard.send_page_view);
238    }
239
240    async fn dispatch_event(&self, event: &AnalyticsEvent) -> AnalyticsResult<()> {
241        let transport = {
242            let guard = self.inner.transport.lock().unwrap();
243            guard.clone()
244        };
245
246        if self.inner.collection_enabled.load(Ordering::SeqCst) {
247            if let Some(transport) = transport {
248                let client_id = self.inner.client_id.lock().unwrap().clone();
249                transport
250                    .send(&client_id, &event.name, &event.params)
251                    .await?
252            }
253        }
254
255        Ok(())
256    }
257
258    async fn configure_measurement_protocol_with_secret_internal(
259        &self,
260        api_secret: impl Into<String>,
261        endpoint: Option<MeasurementProtocolEndpoint>,
262    ) -> AnalyticsResult<()> {
263        let config = self.ensure_dynamic_config().await?;
264        let mut mp_config =
265            MeasurementProtocolConfig::new(config.measurement_id().to_string(), api_secret);
266        if let Some(endpoint) = endpoint {
267            mp_config = mp_config.with_endpoint(endpoint);
268        }
269        self.configure_measurement_protocol(mp_config)
270    }
271
272    async fn ensure_dynamic_config(&self) -> AnalyticsResult<DynamicConfig> {
273        if let Some(cached) = self.inner.config.lock().unwrap().clone() {
274            return Ok(cached);
275        }
276
277        if let Some(local) = from_app_options(&self.inner.app) {
278            let mut guard = self.inner.config.lock().unwrap();
279            *guard = Some(local.clone());
280            self.inner
281                .gtag
282                .inner()
283                .set_measurement_id(Some(local.measurement_id().to_string()));
284            return Ok(local);
285        }
286
287        // Fetch without holding the config mutex to avoid blocking other readers while awaiting.
288        let fetched = fetch_dynamic_config(&self.inner.app).await?;
289        let mut guard = self.inner.config.lock().unwrap();
290        *guard = Some(fetched.clone());
291        self.inner
292            .gtag
293            .inner()
294            .set_measurement_id(Some(fetched.measurement_id().to_string()));
295        Ok(fetched)
296    }
297
298    fn merge_default_event_params(
299        &self,
300        mut params: BTreeMap<String, String>,
301    ) -> BTreeMap<String, String> {
302        let defaults = self.inner.default_event_params.lock().unwrap().clone();
303        for (key, value) in defaults {
304            params.entry(key).or_insert(value);
305        }
306        params
307    }
308
309    /// Enables or disables analytics collection. When disabled, events are still recorded locally
310    /// but are not dispatched through the configured transport.
311    pub fn set_collection_enabled(&self, enabled: bool) {
312        self.inner
313            .collection_enabled
314            .store(enabled, Ordering::SeqCst);
315    }
316
317    /// Returns whether analytics collection is currently enabled.
318    pub fn collection_enabled(&self) -> bool {
319        self.inner.collection_enabled.load(Ordering::SeqCst)
320    }
321
322    #[cfg(test)]
323    fn set_transport_for_tests(&self, transport: Arc<dyn AnalyticsTransport>) {
324        *self.inner.transport.lock().unwrap() = Some(transport);
325    }
326}
327
328fn validate_event_name(name: &str) -> AnalyticsResult<()> {
329    if name.trim().is_empty() {
330        return Err(invalid_argument("Event name must not be empty"));
331    }
332    Ok(())
333}
334
335static ANALYTICS_COMPONENT: LazyLock<Component> = LazyLock::new(|| {
336    Component::new(
337        ANALYTICS_COMPONENT_NAME,
338        Arc::new(analytics_factory),
339        ComponentType::Public,
340    )
341    .with_instantiation_mode(InstantiationMode::Lazy)
342});
343
344fn analytics_factory(
345    container: &crate::component::ComponentContainer,
346    _options: InstanceFactoryOptions,
347) -> Result<DynService, ComponentError> {
348    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
349        ComponentError::InitializationFailed {
350            name: ANALYTICS_COMPONENT_NAME.to_string(),
351            reason: "Firebase app not attached to component container".to_string(),
352        }
353    })?;
354    let analytics = Analytics::new((*app).clone());
355    Ok(Arc::new(analytics) as DynService)
356}
357
358fn ensure_registered() {
359    let component = LazyLock::force(&ANALYTICS_COMPONENT).clone();
360    let _ = app::register_component(component);
361}
362
363fn generate_client_id() -> String {
364    use rand::distributions::Alphanumeric;
365    use rand::Rng;
366
367    rand::thread_rng()
368        .sample_iter(&Alphanumeric)
369        .map(char::from)
370        .take(32)
371        .collect()
372}
373
374pub fn register_analytics_component() {
375    ensure_registered();
376}
377
378pub async fn get_analytics(app: Option<FirebaseApp>) -> AnalyticsResult<Arc<Analytics>> {
379    ensure_registered();
380    let app = match app {
381        Some(app) => app,
382        None => crate::app::get_app(None)
383            .await
384            .map_err(|err| internal_error(err.to_string()))?,
385    };
386
387    let provider = app::get_provider(&app, ANALYTICS_COMPONENT_NAME);
388    provider
389        .get_immediate::<Analytics>()
390        .ok_or_else(|| internal_error("Analytics component not available"))
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::analytics::gtag::GlobalGtagRegistry;
397    use crate::app::initialize_app;
398    use crate::app::{FirebaseAppSettings, FirebaseOptions};
399    use std::collections::BTreeMap;
400    use std::sync::{Arc, LazyLock, Mutex};
401
402    static GTAG_TEST_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
403
404    fn unique_settings() -> FirebaseAppSettings {
405        use std::sync::atomic::{AtomicUsize, Ordering};
406        static COUNTER: AtomicUsize = AtomicUsize::new(0);
407        FirebaseAppSettings {
408            name: Some(format!(
409                "analytics-{}",
410                COUNTER.fetch_add(1, Ordering::SeqCst)
411            )),
412            ..Default::default()
413        }
414    }
415
416    fn reset_gtag_state() {
417        GlobalGtagRegistry::shared().inner().reset();
418    }
419
420    fn gtag_test_guard() -> std::sync::MutexGuard<'static, ()> {
421        GTAG_TEST_MUTEX.lock().unwrap()
422    }
423
424    #[derive(Default, Clone)]
425    struct RecordingTransport {
426        events: Arc<Mutex<Vec<(String, BTreeMap<String, String>)>>>,
427        clients: Arc<Mutex<Vec<String>>>,
428    }
429
430    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
431    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
432    impl AnalyticsTransport for RecordingTransport {
433        async fn send(
434            &self,
435            client_id: &str,
436            event_name: &str,
437            params: &BTreeMap<String, String>,
438        ) -> AnalyticsResult<()> {
439            self.clients.lock().unwrap().push(client_id.to_string());
440            self.events
441                .lock()
442                .unwrap()
443                .push((event_name.to_string(), params.clone()));
444            Ok(())
445        }
446    }
447
448    impl RecordingTransport {
449        fn take_events(&self) -> Vec<(String, BTreeMap<String, String>)> {
450            self.events.lock().unwrap().clone()
451        }
452
453        #[allow(dead_code)]
454        fn clients(&self) -> Vec<String> {
455            self.clients.lock().unwrap().clone()
456        }
457    }
458
459    #[tokio::test(flavor = "current_thread")]
460    async fn log_event_records_entry() {
461        let _guard = gtag_test_guard();
462        reset_gtag_state();
463        let options = FirebaseOptions {
464            project_id: Some("project".into()),
465            measurement_id: Some("G-LOCAL123".into()),
466            ..Default::default()
467        };
468        let app = initialize_app(options, Some(unique_settings()))
469            .await
470            .unwrap();
471        let analytics = get_analytics(Some(app)).await.unwrap();
472        let mut params = BTreeMap::new();
473        params.insert("origin".into(), "test".into());
474        analytics
475            .log_event("test_event", params.clone())
476            .await
477            .unwrap();
478        let events = analytics.recorded_events();
479        assert_eq!(events.len(), 1);
480        assert_eq!(events[0].name, "test_event");
481        assert_eq!(events[0].params, params);
482    }
483
484    #[tokio::test(flavor = "current_thread")]
485    async fn default_event_parameters_are_applied() {
486        let _guard = gtag_test_guard();
487        reset_gtag_state();
488        let options = FirebaseOptions {
489            project_id: Some("project".into()),
490            measurement_id: Some("G-LOCAL789".into()),
491            ..Default::default()
492        };
493        let app = initialize_app(options, Some(unique_settings()))
494            .await
495            .unwrap();
496        let analytics = get_analytics(Some(app)).await.unwrap();
497        analytics.set_default_event_parameters(BTreeMap::from([(
498            "origin".to_string(),
499            "default".to_string(),
500        )]));
501
502        let mut params = BTreeMap::new();
503        params.insert("value".into(), "42".into());
504        analytics.log_event("test", params).await.unwrap();
505
506        let events = analytics.recorded_events();
507        let recorded = &events[0];
508        assert_eq!(recorded.params.get("origin"), Some(&"default".to_string()));
509        assert_eq!(recorded.params.get("value"), Some(&"42".to_string()));
510    }
511
512    #[tokio::test(flavor = "current_thread")]
513    async fn default_event_parameters_do_not_override_explicit_values() {
514        let _guard = gtag_test_guard();
515        reset_gtag_state();
516        let options = FirebaseOptions {
517            project_id: Some("project".into()),
518            measurement_id: Some("G-LOCAL990".into()),
519            ..Default::default()
520        };
521        let app = initialize_app(options, Some(unique_settings()))
522            .await
523            .unwrap();
524        let analytics = get_analytics(Some(app)).await.unwrap();
525        analytics.set_default_event_parameters(BTreeMap::from([(
526            "value".to_string(),
527            "default".to_string(),
528        )]));
529
530        let mut params = BTreeMap::new();
531        params.insert("value".into(), "custom".into());
532        analytics.log_event("test", params).await.unwrap();
533
534        let events = analytics.recorded_events();
535        let recorded = &events[0];
536        assert_eq!(recorded.params.get("value"), Some(&"custom".to_string()));
537    }
538
539    #[tokio::test(flavor = "current_thread")]
540    async fn measurement_config_uses_local_options() {
541        let _guard = gtag_test_guard();
542        reset_gtag_state();
543        let options = FirebaseOptions {
544            project_id: Some("project".into()),
545            measurement_id: Some("G-LOCAL456".into()),
546            app_id: Some("1:123:web:abc".into()),
547            ..Default::default()
548        };
549        let app = initialize_app(options, Some(unique_settings()))
550            .await
551            .unwrap();
552        let analytics = get_analytics(Some(app)).await.unwrap();
553
554        let config = analytics.measurement_config().await.unwrap();
555        assert_eq!(config.measurement_id(), "G-LOCAL456");
556        assert_eq!(config.app_id(), Some("1:123:web:abc"));
557
558        let gtag_state = analytics.gtag_state();
559        assert_eq!(gtag_state.measurement_id, Some("G-LOCAL456".to_string()));
560    }
561
562    #[tokio::test(flavor = "current_thread")]
563    async fn configure_with_secret_requires_measurement_context() {
564        let _guard = gtag_test_guard();
565        reset_gtag_state();
566        let options = FirebaseOptions {
567            project_id: Some("project".into()),
568            ..Default::default()
569        };
570        let app = initialize_app(options, Some(unique_settings()))
571            .await
572            .unwrap();
573        let analytics = get_analytics(Some(app)).await.unwrap();
574
575        let err = analytics
576            .configure_measurement_protocol_with_secret("secret")
577            .await
578            .unwrap_err();
579        assert_eq!(err.code_str(), "analytics/missing-measurement-id");
580    }
581
582    #[tokio::test(flavor = "current_thread")]
583    async fn collection_toggle_controls_state() {
584        let _guard = gtag_test_guard();
585        reset_gtag_state();
586        let options = FirebaseOptions {
587            project_id: Some("project".into()),
588            measurement_id: Some("G-LOCALCOLLECT".into()),
589            ..Default::default()
590        };
591        let app = initialize_app(options, Some(unique_settings()))
592            .await
593            .unwrap();
594        let analytics = get_analytics(Some(app)).await.unwrap();
595
596        assert!(analytics.collection_enabled());
597        analytics.set_collection_enabled(false);
598        assert!(!analytics.collection_enabled());
599        analytics.set_collection_enabled(true);
600        assert!(analytics.collection_enabled());
601    }
602
603    #[tokio::test(flavor = "current_thread")]
604    async fn gtag_state_tracks_defaults_and_config() {
605        let _guard = gtag_test_guard();
606        reset_gtag_state();
607        let options = FirebaseOptions {
608            project_id: Some("project".into()),
609            measurement_id: Some("G-GTAGTEST".into()),
610            ..Default::default()
611        };
612        let app = initialize_app(options, Some(unique_settings()))
613            .await
614            .unwrap();
615        let analytics = get_analytics(Some(app)).await.unwrap();
616
617        analytics.set_default_event_parameters(BTreeMap::from([(
618            "currency".to_string(),
619            "USD".to_string(),
620        )]));
621        analytics.set_consent_defaults(ConsentSettings {
622            entries: BTreeMap::from([(String::from("ad_storage"), String::from("granted"))]),
623        });
624        analytics.apply_settings(AnalyticsSettings {
625            config: BTreeMap::from([(String::from("send_page_view"), String::from("false"))]),
626            send_page_view: Some(false),
627        });
628        // Force measurement configuration resolution so the gtag registry is populated.
629        analytics.measurement_config().await.unwrap();
630
631        let state = analytics.gtag_state();
632        assert_eq!(state.data_layer_name, "dataLayer");
633        assert_eq!(state.measurement_id, Some("G-GTAGTEST".to_string()));
634        assert_eq!(
635            state.default_event_parameters.get("currency"),
636            Some(&"USD".to_string())
637        );
638        assert_eq!(
639            state
640                .consent_settings
641                .as_ref()
642                .and_then(|m| m.get("ad_storage")),
643            Some(&"granted".to_string())
644        );
645        assert_eq!(state.send_page_view, Some(false));
646        assert_eq!(
647            state.config.get("send_page_view"),
648            Some(&"false".to_string())
649        );
650    }
651
652    #[tokio::test(flavor = "current_thread")]
653    async fn measurement_protocol_dispatches_events() {
654        let _guard = gtag_test_guard();
655        reset_gtag_state();
656
657        let options = FirebaseOptions {
658            project_id: Some("project".into()),
659            measurement_id: Some("G-TEST123".into()),
660            ..Default::default()
661        };
662        let app = initialize_app(options, Some(unique_settings()))
663            .await
664            .unwrap();
665        let analytics = get_analytics(Some(app)).await.unwrap();
666
667        let transport = RecordingTransport::default();
668        analytics.set_transport_for_tests(Arc::new(transport.clone()));
669        analytics.set_client_id("client-123");
670
671        let mut params = BTreeMap::new();
672        params.insert("engagement_time_msec".to_string(), "100".to_string());
673
674        analytics
675            .log_event("test_event", params.clone())
676            .await
677            .unwrap();
678
679        let events = transport.take_events();
680        assert_eq!(events.len(), 1);
681        assert_eq!(events[0].0, "test_event");
682        assert_eq!(events[0].1, params);
683    }
684}