firebase_rs_sdk/analytics/
api.rs

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