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 pub fn gtag_state(&self) -> GtagState {
152 self.inner.gtag.inner().snapshot()
153 }
154
155 pub async fn measurement_config(&self) -> AnalyticsResult<DynamicConfig> {
159 self.ensure_dynamic_config().await
160 }
161
162 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 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 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 pub fn set_client_id(&self, client_id: impl Into<String>) {
204 *self.inner.client_id.lock().unwrap() = client_id.into();
205 }
206
207 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 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 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 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 pub fn set_collection_enabled(&self, enabled: bool) {
312 self.inner
313 .collection_enabled
314 .store(enabled, Ordering::SeqCst);
315 }
316
317 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 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}