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 pub fn gtag_state(&self) -> GtagState {
101 self.inner.gtag.inner().snapshot()
102 }
103
104 pub fn measurement_config(&self) -> AnalyticsResult<DynamicConfig> {
108 self.ensure_dynamic_config()
109 }
110
111 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 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 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 pub fn set_client_id(&self, client_id: impl Into<String>) {
151 *self.inner.client_id.lock().unwrap() = client_id.into();
152 }
153
154 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 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 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 pub fn set_collection_enabled(&self, enabled: bool) {
256 self.inner
257 .collection_enabled
258 .store(enabled, Ordering::SeqCst);
259 }
260
261 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 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}