firebase_rs_sdk/analytics/
transport.rs

1use std::collections::BTreeMap;
2use std::time::Duration;
3
4use reqwest::blocking::Client;
5use reqwest::StatusCode;
6use serde::Serialize;
7
8use crate::analytics::error::{internal_error, invalid_argument, network_error, AnalyticsResult};
9
10/// Configuration used to dispatch analytics events through the GA4 Measurement Protocol.
11#[derive(Clone, Debug)]
12pub struct MeasurementProtocolConfig {
13    measurement_id: String,
14    api_secret: String,
15    endpoint: MeasurementProtocolEndpoint,
16    timeout: Duration,
17}
18
19impl MeasurementProtocolConfig {
20    pub fn new(measurement_id: impl Into<String>, api_secret: impl Into<String>) -> Self {
21        Self {
22            measurement_id: measurement_id.into(),
23            api_secret: api_secret.into(),
24            endpoint: MeasurementProtocolEndpoint::Collect,
25            timeout: Duration::from_secs(10),
26        }
27    }
28
29    pub fn with_endpoint(mut self, endpoint: MeasurementProtocolEndpoint) -> Self {
30        self.endpoint = endpoint;
31        self
32    }
33
34    pub fn with_timeout(mut self, timeout: Duration) -> Self {
35        self.timeout = timeout;
36        self
37    }
38
39    pub(crate) fn timeout(&self) -> Duration {
40        self.timeout
41    }
42
43    pub(crate) fn measurement_id(&self) -> &str {
44        &self.measurement_id
45    }
46
47    pub(crate) fn api_secret(&self) -> &str {
48        &self.api_secret
49    }
50}
51
52/// Supported endpoints for the Measurement Protocol.
53#[derive(Clone, Debug)]
54pub enum MeasurementProtocolEndpoint {
55    /// Production collection endpoint: <https://www.google-analytics.com/mp/collect>
56    Collect,
57    /// Debugging endpoint: <https://www.google-analytics.com/debug/mp/collect>
58    DebugCollect,
59    /// Custom endpoint (primarily for testing).
60    Custom(String),
61}
62
63impl MeasurementProtocolEndpoint {
64    fn as_str(&self) -> &str {
65        match self {
66            MeasurementProtocolEndpoint::Collect => "https://www.google-analytics.com/mp/collect",
67            MeasurementProtocolEndpoint::DebugCollect => {
68                "https://www.google-analytics.com/debug/mp/collect"
69            }
70            MeasurementProtocolEndpoint::Custom(url) => url,
71        }
72    }
73}
74
75#[derive(Clone, Debug)]
76pub struct MeasurementProtocolDispatcher {
77    client: Client,
78    config: MeasurementProtocolConfig,
79}
80
81impl MeasurementProtocolDispatcher {
82    /// Creates a new dispatcher that will send events to the GA4 Measurement Protocol endpoint.
83    pub fn new(config: MeasurementProtocolConfig) -> AnalyticsResult<Self> {
84        if config.measurement_id().trim().is_empty() {
85            return Err(invalid_argument(
86                "measurement protocol measurement_id must not be empty",
87            ));
88        }
89        if config.api_secret().trim().is_empty() {
90            return Err(invalid_argument(
91                "measurement protocol api_secret must not be empty",
92            ));
93        }
94        let client = Client::builder()
95            .timeout(config.timeout())
96            .build()
97            .map_err(|err| internal_error(format!("failed to build HTTP client: {err}")))?;
98
99        Ok(Self { client, config })
100    }
101
102    /// Sends a single analytics event via the measurement protocol.
103    ///
104    /// The caller is responsible for providing a stable `client_id`, typically sourced from
105    /// Firebase Installations or another per-device identifier.
106    pub fn send_event(
107        &self,
108        client_id: &str,
109        event_name: &str,
110        params: &BTreeMap<String, String>,
111    ) -> AnalyticsResult<()> {
112        let payload = MeasurementPayload {
113            client_id,
114            events: vec![MeasurementEvent {
115                name: event_name,
116                params,
117            }],
118        };
119
120        let response = self
121            .client
122            .post(self.config.endpoint.as_str())
123            .query(&[
124                ("measurement_id", self.config.measurement_id()),
125                ("api_secret", self.config.api_secret()),
126            ])
127            .json(&payload)
128            .send()
129            .map_err(|err| network_error(format!("failed to send analytics event: {err}")))?;
130
131        if response.status().is_success() {
132            return Ok(());
133        }
134
135        let status = response.status();
136        let body = response
137            .text()
138            .unwrap_or_else(|_| "<unavailable response body>".to_string());
139
140        let message = match status {
141            StatusCode::BAD_REQUEST => {
142                format!("measurement protocol rejected the event (400). Response: {body}")
143            }
144            _ => format!(
145                "measurement protocol request failed with status {status}. Response: {body}"
146            ),
147        };
148
149        Err(network_error(message))
150    }
151
152    pub fn config(&self) -> &MeasurementProtocolConfig {
153        &self.config
154    }
155}
156
157#[derive(Serialize)]
158struct MeasurementPayload<'a> {
159    client_id: &'a str,
160    events: Vec<MeasurementEvent<'a>>,
161}
162
163#[derive(Serialize)]
164struct MeasurementEvent<'a> {
165    name: &'a str,
166    #[serde(rename = "params")]
167    params: &'a BTreeMap<String, String>,
168}