firebase_rs_sdk/analytics/
transport.rs

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