firebase_rs_sdk/performance/
api.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::{Arc, LazyLock, Mutex as StdMutex, RwLock};
4use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
6use async_lock::Mutex;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::app;
11use crate::app::FirebaseApp;
12use crate::app_check::{AppCheckTokenError, AppCheckTokenResult, FirebaseAppCheckInternal};
13use crate::auth::FirebaseAuth;
14use crate::component::types::{
15    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
16};
17use crate::component::{Component, ComponentType};
18use crate::installations::get_installations;
19use crate::performance::constants::{
20    MAX_ATTRIBUTE_NAME_LENGTH, MAX_ATTRIBUTE_VALUE_LENGTH, MAX_METRIC_NAME_LENGTH,
21    OOB_TRACE_PAGE_LOAD_PREFIX, PERFORMANCE_COMPONENT_NAME, RESERVED_ATTRIBUTE_PREFIXES,
22    RESERVED_METRIC_PREFIX,
23};
24use crate::performance::error::{internal_error, invalid_argument, PerformanceResult};
25use crate::performance::instrumentation;
26use crate::performance::storage::{create_trace_store, TraceEnvelope, TraceStoreHandle};
27use crate::performance::transport::{TransportController, TransportOptions};
28#[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
29use crate::platform::environment;
30use crate::platform::runtime;
31use log::debug;
32
33/// User provided configuration toggles mirroring the JavaScript SDK's
34/// [`PerformanceSettings`](https://github.com/firebase/firebase-js-sdk/blob/master/packages/performance/src/public_types.ts).
35///
36/// Leave fields as `None` to keep the app-level defaults derived from
37/// [`FirebaseAppSettings::automatic_data_collection_enabled`](crate::app::FirebaseAppSettings).
38#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
39pub struct PerformanceSettings {
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub data_collection_enabled: Option<bool>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub instrumentation_enabled: Option<bool>,
44}
45
46/// Resolved runtime settings that reflect the effective data collection and
47/// instrumentation flags for a [`Performance`] instance.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct PerformanceRuntimeSettings {
50    data_collection_enabled: bool,
51    instrumentation_enabled: bool,
52}
53
54impl PerformanceRuntimeSettings {
55    fn resolve(app: &FirebaseApp, overrides: Option<&PerformanceSettings>) -> Self {
56        let mut resolved = Self {
57            data_collection_enabled: app.automatic_data_collection_enabled(),
58            instrumentation_enabled: app.automatic_data_collection_enabled(),
59        };
60
61        if let Some(settings) = overrides {
62            if let Some(value) = settings.data_collection_enabled {
63                resolved.data_collection_enabled = value;
64            }
65            if let Some(value) = settings.instrumentation_enabled {
66                resolved.instrumentation_enabled = value;
67            }
68        }
69
70        resolved
71    }
72
73    pub fn data_collection_enabled(&self) -> bool {
74        self.data_collection_enabled
75    }
76
77    pub fn instrumentation_enabled(&self) -> bool {
78        self.instrumentation_enabled
79    }
80}
81
82impl Default for PerformanceRuntimeSettings {
83    fn default() -> Self {
84        Self {
85            data_collection_enabled: true,
86            instrumentation_enabled: true,
87        }
88    }
89}
90
91/// Firebase Performance Monitoring entry point tied to a [`FirebaseApp`].
92///
93/// This struct intentionally mirrors the behaviour of the JS SDK's
94/// `FirebasePerformance` controller: it holds runtime toggles, orchestrates
95/// trace/HTTP instrumentation, and can be retrieved via [`get_performance`].
96#[derive(Clone)]
97pub struct Performance {
98    inner: Arc<PerformanceInner>,
99}
100
101struct PerformanceInner {
102    app: FirebaseApp,
103    traces: Mutex<HashMap<String, PerformanceTrace>>,
104    network_requests: Mutex<Vec<NetworkRequestRecord>>,
105    settings: RwLock<PerformanceRuntimeSettings>,
106    app_check: StdMutex<Option<FirebaseAppCheckInternal>>,
107    auth: StdMutex<Option<AuthContext>>,
108    trace_store: TraceStoreHandle,
109    transport: StdMutex<Option<Arc<TransportController>>>,
110    transport_options: Arc<RwLock<TransportOptions>>,
111    installation_id: Mutex<Option<String>>,
112}
113
114/// Builder for manual traces (`Trace` in the JS SDK).
115#[derive(Clone)]
116pub struct TraceHandle {
117    performance: Performance,
118    name: Arc<str>,
119    state: TraceLifecycle,
120    metrics: HashMap<String, i64>,
121    attributes: HashMap<String, String>,
122    is_auto: bool,
123}
124
125/// Builder for manual network request instrumentation. Mirrors the JS
126/// `NetworkRequestTrace` helper and records timing plus payload metadata.
127#[derive(Clone)]
128pub struct NetworkTraceHandle {
129    performance: Performance,
130    url: String,
131    method: HttpMethod,
132    state: NetworkLifecycle,
133    request_payload_bytes: Option<u64>,
134    response_payload_bytes: Option<u64>,
135    response_code: Option<u16>,
136    response_content_type: Option<String>,
137}
138
139/// HTTP method enum reused by [`NetworkTraceHandle`].
140/// Represents the HTTP verb associated with a [`NetworkTraceHandle`].
141#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
142pub enum HttpMethod {
143    Get,
144    Put,
145    Post,
146    Delete,
147    Head,
148    Patch,
149    Options,
150    Trace,
151    Connect,
152    Custom(String),
153}
154
155impl HttpMethod {
156    /// Returns the canonical uppercase representation used when logging.
157    pub fn as_str(&self) -> &str {
158        match self {
159            HttpMethod::Get => "GET",
160            HttpMethod::Put => "PUT",
161            HttpMethod::Post => "POST",
162            HttpMethod::Delete => "DELETE",
163            HttpMethod::Head => "HEAD",
164            HttpMethod::Patch => "PATCH",
165            HttpMethod::Options => "OPTIONS",
166            HttpMethod::Trace => "TRACE",
167            HttpMethod::Connect => "CONNECT",
168            HttpMethod::Custom(value) => value.as_str(),
169        }
170    }
171}
172
173/// Immutable snapshot of a recorded trace with timing, metrics, and attributes.
174#[derive(Clone, Debug, PartialEq)]
175pub struct PerformanceTrace {
176    pub name: Arc<str>,
177    pub start_time_us: u128,
178    pub duration: Duration,
179    pub metrics: HashMap<String, i64>,
180    pub attributes: HashMap<String, String>,
181    pub is_auto: bool,
182    pub auth_uid: Option<String>,
183}
184
185/// Optional bundle of metrics and attributes passed into
186/// [`TraceHandle::record`].
187#[derive(Clone, Debug, Default, PartialEq, Eq)]
188pub struct TraceRecordOptions {
189    pub metrics: HashMap<String, i64>,
190    pub attributes: HashMap<String, String>,
191}
192
193/// Captured HTTP request metadata that mirrors the payload sent by the JS SDK
194/// when batching network traces to the backend.
195#[derive(Clone, Debug, PartialEq)]
196pub struct NetworkRequestRecord {
197    pub url: String,
198    pub http_method: HttpMethod,
199    pub start_time_us: u128,
200    pub time_to_request_completed_us: u128,
201    pub time_to_response_initiated_us: Option<u128>,
202    pub time_to_response_completed_us: Option<u128>,
203    pub request_payload_bytes: Option<u64>,
204    pub response_payload_bytes: Option<u64>,
205    pub response_code: Option<u16>,
206    pub response_content_type: Option<String>,
207    pub app_check_token: Option<String>,
208}
209
210#[derive(Clone, Debug)]
211enum TraceLifecycle {
212    Idle,
213    Running {
214        started_at: Instant,
215        started_micros: u128,
216    },
217    Completed,
218}
219
220#[derive(Clone, Debug)]
221enum NetworkLifecycle {
222    Idle,
223    Running {
224        started_at: Instant,
225        started_micros: u128,
226        response_initiated: Option<Duration>,
227    },
228    Completed,
229}
230
231#[derive(Clone)]
232enum AuthContext {
233    Firebase(FirebaseAuth),
234    Static(String),
235}
236
237impl AuthContext {
238    fn current_uid(&self) -> Option<String> {
239        match self {
240            AuthContext::Firebase(auth) => auth.current_user().map(|user| user.uid().to_string()),
241            AuthContext::Static(uid) if uid.is_empty() => None,
242            AuthContext::Static(uid) => Some(uid.clone()),
243        }
244    }
245}
246
247impl Performance {
248    fn new(app: FirebaseApp, settings: Option<PerformanceSettings>) -> Self {
249        let resolved = PerformanceRuntimeSettings::resolve(&app, settings.as_ref());
250        let trace_store = create_trace_store(&app);
251        let transport_options = Arc::new(RwLock::new(TransportOptions::default()));
252        let inner = PerformanceInner {
253            app,
254            traces: Mutex::new(HashMap::new()),
255            network_requests: Mutex::new(Vec::new()),
256            settings: RwLock::new(resolved),
257            app_check: StdMutex::new(None),
258            auth: StdMutex::new(None),
259            trace_store,
260            transport: StdMutex::new(None),
261            transport_options,
262            installation_id: Mutex::new(None),
263        };
264        let performance = Self {
265            inner: Arc::new(inner),
266        };
267        performance.initialize_background_tasks();
268        performance
269    }
270
271    fn initialize_background_tasks(&self) {
272        instrumentation::initialize(self);
273        let controller = TransportController::new(
274            self.clone(),
275            self.inner.trace_store.clone(),
276            self.inner.transport_options.clone(),
277        );
278        if let Ok(mut guard) = self.inner.transport.lock() {
279            *guard = Some(controller);
280        }
281    }
282
283    /// Returns the [`FirebaseApp`] that owns this performance instance.
284    pub fn app(&self) -> &FirebaseApp {
285        &self.inner.app
286    }
287
288    /// Resolves the currently effective runtime settings.
289    pub fn settings(&self) -> PerformanceRuntimeSettings {
290        self.inner
291            .settings
292            .read()
293            .expect("settings lock poisoned")
294            .clone()
295    }
296
297    /// Applies the provided settings, overwriting only the `Some` fields.
298    pub fn apply_settings(&self, settings: PerformanceSettings) {
299        let mut guard = self.inner.settings.write().expect("settings lock poisoned");
300        if let Some(value) = settings.data_collection_enabled {
301            guard.data_collection_enabled = value;
302        }
303        if let Some(value) = settings.instrumentation_enabled {
304            guard.instrumentation_enabled = value;
305        }
306    }
307
308    /// Enables or disables manual/custom trace collection.
309    pub fn set_data_collection_enabled(&self, enabled: bool) {
310        self.apply_settings(PerformanceSettings {
311            data_collection_enabled: Some(enabled),
312            ..Default::default()
313        });
314    }
315
316    /// Enables or disables automatic instrumentation (network/OOB traces).
317    pub fn set_instrumentation_enabled(&self, enabled: bool) {
318        self.apply_settings(PerformanceSettings {
319            instrumentation_enabled: Some(enabled),
320            ..Default::default()
321        });
322    }
323
324    /// Returns whether manual/custom traces are currently recorded.
325    pub fn data_collection_enabled(&self) -> bool {
326        self.settings().data_collection_enabled()
327    }
328
329    /// Returns whether automatic instrumentation (network/OOB) is enabled.
330    pub fn instrumentation_enabled(&self) -> bool {
331        self.settings().instrumentation_enabled()
332    }
333
334    /// Associates an App Check instance whose tokens will be attached to
335    /// outgoing network trace records.
336    pub fn attach_app_check(&self, app_check: FirebaseAppCheckInternal) {
337        let mut guard = self.inner.app_check.lock().expect("app_check lock");
338        *guard = Some(app_check);
339    }
340
341    /// Removes any App Check integration.
342    pub fn clear_app_check(&self) {
343        let mut guard = self.inner.app_check.lock().expect("app_check lock");
344        guard.take();
345    }
346
347    /// Associates a [`FirebaseAuth`] instance so recorded traces can capture
348    /// the active user ID (mirrors the JS SDK's `setUserId`).
349    pub fn attach_auth(&self, auth: FirebaseAuth) {
350        let mut guard = self.inner.auth.lock().expect("auth lock");
351        *guard = Some(AuthContext::Firebase(auth));
352    }
353
354    /// Manually overrides the authenticated user ID attribute that will be
355    /// stored with subsequent traces.
356    pub fn set_authenticated_user_id(&self, uid: Option<&str>) {
357        let mut guard = self.inner.auth.lock().expect("auth lock");
358        match uid {
359            Some(value) => *guard = Some(AuthContext::Static(value.to_string())),
360            None => {
361                guard.take();
362            }
363        }
364    }
365
366    /// Clears any stored authentication context.
367    pub fn clear_auth(&self) {
368        let mut guard = self.inner.auth.lock().expect("auth lock");
369        guard.take();
370    }
371
372    /// Creates a new manual trace. Call [`TraceHandle::start`] /
373    /// [`stop`](TraceHandle::stop) to record the timing metrics.
374    ///
375    /// # Examples
376    /// ```rust,no_run
377    /// # use firebase_rs_sdk::performance::{get_performance, PerformanceResult};
378    /// # async fn demo(app: firebase_rs_sdk::app::FirebaseApp) -> PerformanceResult<()> {
379    /// let perf = get_performance(Some(app)).await?;
380    /// let mut trace = perf.new_trace("warmup")?;
381    /// trace.start()?;
382    /// let _ = trace.stop().await?;
383    /// # Ok(())
384    /// # }
385    /// ```
386    pub fn new_trace(&self, name: &str) -> PerformanceResult<TraceHandle> {
387        validate_trace_name(name)?;
388        Ok(TraceHandle {
389            performance: self.clone(),
390            name: Arc::from(name.to_string()),
391            state: TraceLifecycle::Idle,
392            metrics: HashMap::new(),
393            attributes: HashMap::new(),
394            is_auto: false,
395        })
396    }
397
398    /// Creates a manual network trace, mirroring the JS SDK's
399    /// `performance.traceNetworkRequest` helper.
400    ///
401    /// # Examples
402    /// ```rust,no_run
403    /// # use firebase_rs_sdk::performance::{get_performance, HttpMethod, PerformanceResult};
404    /// # async fn demo(app: firebase_rs_sdk::app::FirebaseApp) -> PerformanceResult<()> {
405    /// let perf = get_performance(Some(app)).await?;
406    /// let mut req = perf.new_network_request("https://example.com", HttpMethod::Get)?;
407    /// req.start()?;
408    /// let _record = req.stop().await?;
409    /// # Ok(())
410    /// # }
411    /// ```
412    pub fn new_network_request(
413        &self,
414        url: &str,
415        method: HttpMethod,
416    ) -> PerformanceResult<NetworkTraceHandle> {
417        if url.trim().is_empty() {
418            return Err(invalid_argument("Request URL must not be empty"));
419        }
420        Ok(NetworkTraceHandle {
421            performance: self.clone(),
422            url: url.to_string(),
423            method,
424            state: NetworkLifecycle::Idle,
425            request_payload_bytes: None,
426            response_payload_bytes: None,
427            response_code: None,
428            response_content_type: None,
429        })
430    }
431
432    /// Returns the most recently recorded trace with the provided name.
433    pub async fn recorded_trace(&self, name: &str) -> Option<PerformanceTrace> {
434        self.inner.traces.lock().await.get(name).cloned()
435    }
436
437    /// Returns the latest network trace captured for the given URL (without
438    /// query parameters), if any.
439    pub async fn recorded_network_request(&self, url: &str) -> Option<NetworkRequestRecord> {
440        let traces = self.inner.network_requests.lock().await;
441        traces
442            .iter()
443            .rev()
444            .find(|record| record.url == url)
445            .cloned()
446    }
447
448    /// Overrides the transport configuration used for batching uploads.
449    pub fn configure_transport(&self, options: TransportOptions) {
450        if let Ok(mut guard) = self.inner.transport_options.write() {
451            *guard = options;
452        }
453        if let Some(controller) = self.transport_controller() {
454            controller.trigger_flush();
455        }
456    }
457
458    /// Forces an immediate transport flush.
459    pub async fn flush_transport(&self) -> PerformanceResult<()> {
460        if let Some(controller) = self.transport_controller() {
461            controller.flush_once().await
462        } else {
463            Ok(())
464        }
465    }
466
467    #[cfg_attr(
468        not(all(feature = "wasm-web", target_arch = "wasm32")),
469        allow(dead_code)
470    )]
471    pub(crate) async fn record_auto_trace(
472        self,
473        name: &str,
474        start_time_us: u128,
475        duration: Duration,
476        metrics: HashMap<String, i64>,
477        attributes: HashMap<String, String>,
478    ) -> PerformanceResult<()> {
479        let trace = PerformanceTrace {
480            name: Arc::from(name.to_string()),
481            start_time_us,
482            duration,
483            metrics,
484            attributes,
485            is_auto: true,
486            auth_uid: self.auth_uid(),
487        };
488        self.store_trace(trace).await
489    }
490
491    #[cfg_attr(
492        not(all(feature = "wasm-web", target_arch = "wasm32")),
493        allow(dead_code)
494    )]
495    pub(crate) async fn record_auto_network(
496        self,
497        record: NetworkRequestRecord,
498    ) -> PerformanceResult<()> {
499        self.store_network_request(record).await
500    }
501
502    pub(crate) async fn installation_id(&self) -> Option<String> {
503        if let Some(cached) = self.inner.installation_id.lock().await.clone() {
504            return Some(cached);
505        }
506        let installations = get_installations(Some(self.app().clone())).ok()?;
507        let fid = installations.get_id().await.ok()?;
508        let mut guard = self.inner.installation_id.lock().await;
509        *guard = Some(fid.clone());
510        Some(fid)
511    }
512
513    async fn store_trace(&self, trace: PerformanceTrace) -> PerformanceResult<()> {
514        if !self.data_collection_enabled() {
515            return Ok(());
516        }
517        let name = trace.name.to_string();
518        self.inner
519            .traces
520            .lock()
521            .await
522            .insert(name.clone(), trace.clone());
523        if let Err(err) = self
524            .inner
525            .trace_store
526            .push(TraceEnvelope::Trace(trace))
527            .await
528        {
529            debug!("failed to persist trace {name}: {}", err);
530        }
531        if let Some(controller) = self.transport_controller() {
532            controller.trigger_flush();
533        }
534        Ok(())
535    }
536
537    async fn store_network_request(&self, record: NetworkRequestRecord) -> PerformanceResult<()> {
538        if !self.instrumentation_enabled() {
539            return Ok(());
540        }
541        const MAX_HISTORY: usize = 50;
542        let mut traces = self.inner.network_requests.lock().await;
543        if traces.len() == MAX_HISTORY {
544            traces.remove(0);
545        }
546        traces.push(record.clone());
547        if let Err(err) = self
548            .inner
549            .trace_store
550            .push(TraceEnvelope::Network(record))
551            .await
552        {
553            debug!("failed to persist network trace: {}", err);
554        }
555        if let Some(controller) = self.transport_controller() {
556            controller.trigger_flush();
557        }
558        Ok(())
559    }
560
561    fn auth_uid(&self) -> Option<String> {
562        let ctx = self.inner.auth.lock().ok().and_then(|guard| guard.clone());
563        ctx.and_then(|ctx| ctx.current_uid())
564    }
565
566    fn transport_controller(&self) -> Option<Arc<TransportController>> {
567        self.inner
568            .transport
569            .lock()
570            .ok()
571            .and_then(|guard| guard.as_ref().cloned())
572    }
573
574    async fn app_check_token(&self) -> Option<String> {
575        let provider = self
576            .inner
577            .app_check
578            .lock()
579            .ok()
580            .and_then(|guard| guard.clone())?;
581        match provider.get_token(false).await {
582            Ok(result) => normalize_token_result(result),
583            Err(err) => cached_token_from_error(&err),
584        }
585    }
586}
587
588impl fmt::Debug for Performance {
589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590        f.debug_struct("Performance")
591            .field("app", self.app())
592            .finish()
593    }
594}
595
596fn normalize_token_result(result: AppCheckTokenResult) -> Option<String> {
597    if result.token.is_empty() {
598        None
599    } else {
600        Some(result.token)
601    }
602}
603
604fn cached_token_from_error(error: &AppCheckTokenError) -> Option<String> {
605    error.cached_token().and_then(|token| {
606        if token.token.is_empty() {
607            None
608        } else {
609            Some(token.token.clone())
610        }
611    })
612}
613
614impl TraceHandle {
615    /// Starts the trace timing measurement.
616    pub fn start(&mut self) -> PerformanceResult<()> {
617        match self.state {
618            TraceLifecycle::Idle => {
619                self.state = TraceLifecycle::Running {
620                    started_at: Instant::now(),
621                    started_micros: now_micros(),
622                };
623                Ok(())
624            }
625            TraceLifecycle::Running { .. } => {
626                Err(invalid_argument("Trace has already been started"))
627            }
628            TraceLifecycle::Completed => Err(invalid_argument("Trace has already completed")),
629        }
630    }
631
632    /// Sets (or replaces) a custom metric value.
633    pub fn put_metric(&mut self, name: &str, value: i64) -> PerformanceResult<()> {
634        validate_metric_name(name, &self.name)?;
635        self.metrics.insert(name.to_string(), value);
636        Ok(())
637    }
638
639    /// Increments a custom metric by `delta` (defaults to `1` when omitted in JS).
640    pub fn increment_metric(&mut self, name: &str, delta: i64) -> PerformanceResult<()> {
641        validate_metric_name(name, &self.name)?;
642        let entry = self.metrics.entry(name.to_string()).or_insert(0);
643        *entry = entry.saturating_add(delta);
644        Ok(())
645    }
646
647    /// Returns the current value for the provided metric (zero when unset).
648    pub fn get_metric(&self, name: &str) -> i64 {
649        *self.metrics.get(name).unwrap_or(&0)
650    }
651
652    /// Stores (or replaces) a custom attribute on the trace.
653    pub fn put_attribute(&mut self, name: &str, value: &str) -> PerformanceResult<()> {
654        validate_attribute_name(name)?;
655        validate_attribute_value(value)?;
656        self.attributes.insert(name.to_string(), value.to_string());
657        Ok(())
658    }
659
660    /// Removes a stored attribute, if present.
661    pub fn remove_attribute(&mut self, name: &str) {
662        self.attributes.remove(name);
663    }
664
665    /// Reads an attribute value by name.
666    pub fn get_attribute(&self, name: &str) -> Option<&str> {
667        self.attributes.get(name).map(|value| value.as_str())
668    }
669
670    /// Returns the full attribute map for inspection.
671    pub fn attributes(&self) -> &HashMap<String, String> {
672        &self.attributes
673    }
674
675    /// Records a trace with externally captured timestamps, mirroring the JS
676    /// SDK's `trace.record(startTime, duration, options)` API.
677    ///
678    /// # Examples
679    /// ```rust,no_run
680    /// # use firebase_rs_sdk::performance::{get_performance, PerformanceResult};
681    /// # use std::time::Duration;
682    /// # async fn demo(app: firebase_rs_sdk::app::FirebaseApp) -> PerformanceResult<()> {
683    /// let perf = get_performance(Some(app)).await?;
684    /// let trace = perf.new_trace("cached").unwrap();
685    /// let start = std::time::SystemTime::now();
686    /// trace.record(start, Duration::from_millis(5), None).await?;
687    /// # Ok(())
688    /// # }
689    /// ```
690    pub async fn record(
691        &self,
692        start_time: SystemTime,
693        duration: Duration,
694        options: Option<TraceRecordOptions>,
695    ) -> PerformanceResult<PerformanceTrace> {
696        if duration.is_zero() {
697            return Err(invalid_argument("Trace duration must be positive"));
698        }
699        let mut metrics = HashMap::new();
700        let mut attributes = HashMap::new();
701        if let Some(opts) = options {
702            metrics = opts.metrics;
703            attributes = opts.attributes;
704        }
705        let trace = PerformanceTrace {
706            name: self.name.clone(),
707            start_time_us: timestamp_micros(start_time),
708            duration,
709            metrics,
710            attributes,
711            is_auto: self.is_auto,
712            auth_uid: self.performance.auth_uid(),
713        };
714        self.performance.store_trace(trace.clone()).await?;
715        Ok(trace)
716    }
717
718    /// Stops the trace, finalising the timing measurement.
719    pub async fn stop(mut self) -> PerformanceResult<PerformanceTrace> {
720        let (started_at, started_micros) = match self.state {
721            TraceLifecycle::Running {
722                started_at,
723                started_micros,
724            } => (started_at, started_micros),
725            TraceLifecycle::Idle => {
726                return Err(invalid_argument("Trace must be started before stopping"))
727            }
728            TraceLifecycle::Completed => return Err(invalid_argument("Trace already completed")),
729        };
730        self.state = TraceLifecycle::Completed;
731        let trace = PerformanceTrace {
732            name: self.name.clone(),
733            start_time_us: started_micros,
734            duration: started_at.elapsed(),
735            metrics: self.metrics.clone(),
736            attributes: self.attributes.clone(),
737            is_auto: self.is_auto,
738            auth_uid: self.performance.auth_uid(),
739        };
740        self.performance.store_trace(trace.clone()).await?;
741        Ok(trace)
742    }
743}
744
745impl NetworkTraceHandle {
746    /// Marks the beginning of the manual network request measurement.
747    pub fn start(&mut self) -> PerformanceResult<()> {
748        match self.state {
749            NetworkLifecycle::Idle => {
750                self.state = NetworkLifecycle::Running {
751                    started_at: Instant::now(),
752                    started_micros: now_micros(),
753                    response_initiated: None,
754                };
755                Ok(())
756            }
757            NetworkLifecycle::Running { .. } => {
758                Err(invalid_argument("Network trace already started"))
759            }
760            NetworkLifecycle::Completed => Err(invalid_argument("Network trace already completed")),
761        }
762    }
763
764    /// Records when the response headers have been received.
765    pub fn mark_response_initiated(&mut self) -> PerformanceResult<()> {
766        match &mut self.state {
767            NetworkLifecycle::Running {
768                response_initiated,
769                started_at,
770                ..
771            } => {
772                if response_initiated.is_some() {
773                    Err(invalid_argument("Response already marked as initiated"))
774                } else {
775                    *response_initiated = Some(started_at.elapsed());
776                    Ok(())
777                }
778            }
779            _ => Err(invalid_argument("Response can only be marked after start")),
780        }
781    }
782
783    /// Annotates the request payload size.
784    pub fn set_request_payload_bytes(&mut self, bytes: u64) {
785        self.request_payload_bytes = Some(bytes);
786    }
787
788    /// Annotates the response payload size.
789    pub fn set_response_payload_bytes(&mut self, bytes: u64) {
790        self.response_payload_bytes = Some(bytes);
791    }
792
793    /// Stores the final HTTP response code.
794    pub fn set_response_code(&mut self, code: u16) -> PerformanceResult<()> {
795        if (100..=599).contains(&code) {
796            self.response_code = Some(code);
797            Ok(())
798        } else {
799            Err(invalid_argument(
800                "HTTP status code must be between 100 and 599",
801            ))
802        }
803    }
804
805    /// Stores the response `Content-Type` header (if known).
806    pub fn set_response_content_type(&mut self, content_type: impl Into<String>) {
807        self.response_content_type = Some(content_type.into());
808    }
809
810    /// Completes the network trace, returning the recorded timings.
811    pub async fn stop(mut self) -> PerformanceResult<NetworkRequestRecord> {
812        let (started_at, started_micros, response_initiated) = match &self.state {
813            NetworkLifecycle::Running {
814                started_at,
815                started_micros,
816                response_initiated,
817            } => (*started_at, *started_micros, *response_initiated),
818            NetworkLifecycle::Idle => {
819                return Err(invalid_argument(
820                    "Network trace must be started before stopping",
821                ))
822            }
823            NetworkLifecycle::Completed => {
824                return Err(invalid_argument("Network trace already completed"))
825            }
826        };
827        self.state = NetworkLifecycle::Completed;
828        let duration = started_at.elapsed();
829        let response_initiated_us = response_initiated.map(|value| value.as_micros());
830        let record = NetworkRequestRecord {
831            url: self.url.clone(),
832            http_method: self.method.clone(),
833            start_time_us: started_micros,
834            time_to_request_completed_us: duration.as_micros(),
835            time_to_response_initiated_us: response_initiated_us,
836            time_to_response_completed_us: Some(duration.as_micros()),
837            request_payload_bytes: self.request_payload_bytes,
838            response_payload_bytes: self.response_payload_bytes,
839            response_code: self.response_code,
840            response_content_type: self.response_content_type.clone(),
841            app_check_token: self.performance.app_check_token().await,
842        };
843        self.performance
844            .store_network_request(record.clone())
845            .await?;
846        Ok(record)
847    }
848}
849
850fn validate_trace_name(name: &str) -> PerformanceResult<()> {
851    if name.trim().is_empty() {
852        Err(invalid_argument("Trace name must not be empty"))
853    } else {
854        Ok(())
855    }
856}
857
858fn validate_metric_name(name: &str, trace_name: &str) -> PerformanceResult<()> {
859    if name.is_empty() || name.len() > MAX_METRIC_NAME_LENGTH {
860        return Err(invalid_argument("Metric name must be 1-100 characters"));
861    }
862    if name.starts_with(RESERVED_METRIC_PREFIX)
863        && !trace_name.starts_with(OOB_TRACE_PAGE_LOAD_PREFIX)
864    {
865        return Err(invalid_argument(
866            "Metric names starting with '_' are reserved for auto traces",
867        ));
868    }
869    Ok(())
870}
871
872fn validate_attribute_name(name: &str) -> PerformanceResult<()> {
873    if name.is_empty() || name.len() > MAX_ATTRIBUTE_NAME_LENGTH {
874        return Err(invalid_argument("Attribute name must be 1-40 characters"));
875    }
876    if !name
877        .chars()
878        .next()
879        .map(|ch| ch.is_ascii_alphabetic())
880        .unwrap_or(false)
881    {
882        return Err(invalid_argument(
883            "Attribute names must start with an ASCII letter",
884        ));
885    }
886    if !name
887        .chars()
888        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
889    {
890        return Err(invalid_argument(
891            "Attribute names may only contain letters, numbers, and '_'",
892        ));
893    }
894    if RESERVED_ATTRIBUTE_PREFIXES
895        .iter()
896        .any(|prefix| name.starts_with(prefix))
897    {
898        return Err(invalid_argument("Attribute prefix is reserved"));
899    }
900    Ok(())
901}
902
903fn validate_attribute_value(value: &str) -> PerformanceResult<()> {
904    if value.is_empty() || value.len() > MAX_ATTRIBUTE_VALUE_LENGTH {
905        Err(invalid_argument("Attribute value must be 1-100 characters"))
906    } else {
907        Ok(())
908    }
909}
910
911fn timestamp_micros(time: SystemTime) -> u128 {
912    time.duration_since(UNIX_EPOCH)
913        .map(|duration| duration.as_micros())
914        .unwrap_or(0)
915}
916
917fn now_micros() -> u128 {
918    timestamp_micros(runtime::now())
919}
920
921/// Returns `true` when performance monitoring is expected to work on the
922/// current platform (matches the checks performed in the JS SDK).
923pub fn is_supported() -> bool {
924    #[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
925    {
926        environment::is_browser()
927    }
928
929    #[cfg(not(all(target_arch = "wasm32", feature = "wasm-web")))]
930    {
931        true
932    }
933}
934
935static PERFORMANCE_COMPONENT: LazyLock<()> = LazyLock::new(|| {
936    let component = Component::new(
937        PERFORMANCE_COMPONENT_NAME,
938        Arc::new(performance_factory),
939        ComponentType::Public,
940    )
941    .with_instantiation_mode(InstantiationMode::Lazy);
942    let _ = app::register_component(component);
943});
944
945fn performance_factory(
946    container: &crate::component::ComponentContainer,
947    options: InstanceFactoryOptions,
948) -> Result<DynService, ComponentError> {
949    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
950        ComponentError::InitializationFailed {
951            name: PERFORMANCE_COMPONENT_NAME.to_string(),
952            reason: "Firebase app not attached to component container".to_string(),
953        }
954    })?;
955
956    let settings = match options.options {
957        Value::Null => None,
958        value => Some(serde_json::from_value(value).map_err(|err| {
959            ComponentError::InitializationFailed {
960                name: PERFORMANCE_COMPONENT_NAME.to_string(),
961                reason: format!("invalid settings: {err}"),
962            }
963        })?),
964    };
965
966    let performance = Performance::new((*app).clone(), settings);
967    Ok(Arc::new(performance) as DynService)
968}
969
970fn ensure_registered() {
971    LazyLock::force(&PERFORMANCE_COMPONENT);
972}
973
974/// Registers the performance component in the shared container (normally invoked automatically).
975pub fn register_performance_component() {
976    ensure_registered();
977}
978
979/// Initializes the performance component with explicit settings (akin to the
980/// JS SDK's `initializePerformance`). Subsequent calls with a different
981/// configuration will fail.
982pub async fn initialize_performance(
983    app: FirebaseApp,
984    settings: Option<PerformanceSettings>,
985) -> PerformanceResult<Arc<Performance>> {
986    ensure_registered();
987    let provider = app::get_provider(&app, PERFORMANCE_COMPONENT_NAME);
988    let options_value = match settings {
989        Some(settings) => serde_json::to_value(&settings)
990            .map_err(|err| internal_error(format!("failed to serialize settings: {err}")))?,
991        None => Value::Null,
992    };
993    provider
994        .initialize::<Performance>(options_value, None)
995        .map_err(|err| match err {
996            ComponentError::InstanceAlreadyInitialized { .. } => {
997                invalid_argument("Performance has already been initialized for this app")
998            }
999            other => internal_error(other.to_string()),
1000        })
1001}
1002
1003/// Resolves (or lazily creates) the [`Performance`] instance for the provided
1004/// app. When `app` is `None`, the default app is used.
1005pub async fn get_performance(app: Option<FirebaseApp>) -> PerformanceResult<Arc<Performance>> {
1006    ensure_registered();
1007    let app = match app {
1008        Some(app) => app,
1009        None => crate::app::get_app(None)
1010            .await
1011            .map_err(|err| internal_error(err.to_string()))?,
1012    };
1013
1014    let provider = app::get_provider(&app, PERFORMANCE_COMPONENT_NAME);
1015    if let Some(perf) = provider.get_immediate::<Performance>() {
1016        return Ok(perf);
1017    }
1018
1019    match provider.initialize::<Performance>(Value::Null, None) {
1020        Ok(perf) => Ok(perf),
1021        Err(ComponentError::InstanceUnavailable { .. }) => provider
1022            .get_immediate::<Performance>()
1023            .ok_or_else(|| internal_error("Performance component not available")),
1024        Err(err) => Err(internal_error(err.to_string())),
1025    }
1026}
1027
1028#[cfg(all(test, not(target_arch = "wasm32")))]
1029mod tests {
1030    use super::*;
1031    use crate::app::initialize_app;
1032    use crate::app::{FirebaseAppSettings, FirebaseOptions};
1033    use crate::app_check::{
1034        box_app_check_future, initialize_app_check, AppCheckOptions, AppCheckProvider,
1035        AppCheckProviderFuture, AppCheckResult, AppCheckToken,
1036    };
1037    use crate::performance::TransportOptions;
1038    use httpmock::prelude::*;
1039    use std::sync::Arc;
1040    use tokio::time::sleep;
1041
1042    fn disable_transport() {
1043        std::env::set_var("FIREBASE_PERF_DISABLE_TRANSPORT", "1");
1044    }
1045
1046    async fn test_app(name: &str) -> FirebaseApp {
1047        let options = FirebaseOptions {
1048            project_id: Some("project".into()),
1049            ..Default::default()
1050        };
1051        initialize_app(
1052            options,
1053            Some(FirebaseAppSettings {
1054                name: Some(name.to_string()),
1055                ..Default::default()
1056            }),
1057        )
1058        .await
1059        .expect("create app")
1060    }
1061
1062    #[tokio::test(flavor = "current_thread")]
1063    async fn trace_records_metrics_and_attributes() {
1064        disable_transport();
1065        let app = test_app("perf-trace").await;
1066        let performance = get_performance(Some(app)).await.unwrap();
1067        let mut trace = performance.new_trace("load").unwrap();
1068        trace.put_metric("items", 3).unwrap();
1069        trace.increment_metric("items", 2).unwrap();
1070        trace.put_attribute("locale", "en-US").unwrap();
1071        trace.start().unwrap();
1072        sleep(Duration::from_millis(5)).await;
1073        let result = trace.stop().await.unwrap();
1074        assert_eq!(result.metrics.get("items"), Some(&5));
1075        assert_eq!(result.attributes.get("locale"), Some(&"en-US".to_string()));
1076    }
1077
1078    #[tokio::test(flavor = "current_thread")]
1079    async fn record_api_stores_trace_once() {
1080        disable_transport();
1081        let app = test_app("perf-record").await;
1082        let performance = get_performance(Some(app)).await.unwrap();
1083        let trace = performance.new_trace("bootstrap").unwrap();
1084        let start = runtime::now();
1085        let trace = trace
1086            .record(start, Duration::from_millis(10), None)
1087            .await
1088            .unwrap();
1089        assert_eq!(trace.duration.as_millis(), 10);
1090        let stored = performance.recorded_trace("bootstrap").await.unwrap();
1091        assert_eq!(stored.duration.as_millis(), 10);
1092    }
1093
1094    #[tokio::test(flavor = "current_thread")]
1095    async fn network_request_collects_payload_and_status() {
1096        disable_transport();
1097        let app = test_app("perf-network").await;
1098        let performance = get_performance(Some(app)).await.unwrap();
1099        let mut request = performance
1100            .new_network_request("https://example.com", HttpMethod::Post)
1101            .unwrap();
1102        request.start().unwrap();
1103        sleep(Duration::from_millis(5)).await;
1104        request.mark_response_initiated().unwrap();
1105        request.set_request_payload_bytes(512);
1106        request.set_response_payload_bytes(1024);
1107        request.set_response_code(200).unwrap();
1108        request.set_response_content_type("application/json");
1109        let record = request.stop().await.unwrap();
1110        assert_eq!(record.response_code, Some(200));
1111        assert_eq!(record.request_payload_bytes, Some(512));
1112        assert!(record.time_to_request_completed_us >= 5_000);
1113    }
1114
1115    #[derive(Clone)]
1116    struct StaticAppCheckProvider;
1117
1118    impl AppCheckProvider for StaticAppCheckProvider {
1119        fn get_token(&self) -> AppCheckProviderFuture<'_, AppCheckResult<AppCheckToken>> {
1120            box_app_check_future(async move {
1121                AppCheckToken::with_ttl("app-check-token", Duration::from_secs(60))
1122            })
1123        }
1124    }
1125
1126    async fn attach_app_check(performance: &Performance, app: &FirebaseApp) {
1127        let options = AppCheckOptions::new(Arc::new(StaticAppCheckProvider));
1128        let app_check = initialize_app_check(Some(app.clone()), options)
1129            .await
1130            .expect("initialize app check");
1131        performance.attach_app_check(FirebaseAppCheckInternal::new(app_check));
1132    }
1133
1134    #[tokio::test(flavor = "current_thread")]
1135    async fn network_request_includes_app_check_token() {
1136        disable_transport();
1137        let app = test_app("perf-app-check").await;
1138        let performance = get_performance(Some(app.clone())).await.unwrap();
1139        attach_app_check(&performance, &app).await;
1140        let mut request = performance
1141            .new_network_request("https://example.com", HttpMethod::Get)
1142            .unwrap();
1143        request.start().unwrap();
1144        let record = request.stop().await.unwrap();
1145        assert_eq!(record.app_check_token.as_deref(), Some("app-check-token"));
1146    }
1147
1148    #[tokio::test(flavor = "current_thread")]
1149    async fn initialize_performance_respects_settings() {
1150        disable_transport();
1151        let app = test_app("perf-init").await;
1152        let settings = PerformanceSettings {
1153            data_collection_enabled: Some(false),
1154            instrumentation_enabled: Some(false),
1155        };
1156        let perf = initialize_performance(app.clone(), Some(settings.clone()))
1157            .await
1158            .unwrap();
1159        assert!(!perf.data_collection_enabled());
1160        assert!(!perf.instrumentation_enabled());
1161
1162        let err = initialize_performance(app, Some(settings))
1163            .await
1164            .unwrap_err();
1165        assert_eq!(err.code_str(), "performance/invalid-argument");
1166    }
1167
1168    #[cfg_attr(target_os = "linux", ignore = "localhost sockets disabled in sandbox")]
1169    #[tokio::test(flavor = "current_thread")]
1170    async fn transport_flushes_to_custom_endpoint() {
1171        std::env::remove_var("FIREBASE_PERF_DISABLE_TRANSPORT");
1172        let server = MockServer::start();
1173        let mock = server.mock(|when, then| {
1174            when.method(POST).path("/upload");
1175            then.status(200);
1176        });
1177
1178        let app = test_app("perf-transport").await;
1179        let performance = get_performance(Some(app)).await.unwrap();
1180        performance.configure_transport(TransportOptions {
1181            endpoint: Some(server.url("/upload")),
1182            api_key: None,
1183            flush_interval: Some(Duration::from_millis(10)),
1184            max_batch_size: Some(1),
1185        });
1186
1187        let mut trace = performance.new_trace("upload").unwrap();
1188        trace.start().unwrap();
1189        trace.stop().await.unwrap();
1190        performance.flush_transport().await.unwrap();
1191        sleep(Duration::from_millis(50)).await;
1192        mock.assert_hits(1);
1193        disable_transport();
1194    }
1195}