use rand::RngExt;
use serde_json::{Value, json};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
sync::{Arc, Mutex as SyncMutex},
time::Duration,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{
config::Config,
dispatcher::EventDispatcher,
sys::{self, SystemProperties},
};
static SESSION_TIMEOUT: Duration = Duration::from_secs(4 * 60 * 60);
pub fn new_session_id() -> String {
let epoch_in_seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs();
let mut rng = rand::rng();
let random: u64 = rng.random_range(0..=99999999);
let id = epoch_in_seconds * 100_000_000 + random;
id.to_string()
}
#[derive(Debug, Clone)]
pub struct TrackingSession {
pub id: String,
pub last_touch_ts: OffsetDateTime,
}
impl TrackingSession {
fn new(id: Option<String>) -> Self {
Self {
id: id
.filter(|id| !id.is_empty())
.unwrap_or_else(new_session_id),
last_touch_ts: OffsetDateTime::now_utc(),
}
}
}
pub struct AptabaseClient {
is_enabled: bool,
session: SyncMutex<TrackingSession>,
dispatcher: Arc<EventDispatcher>,
app_version: String,
sys_info: SystemProperties,
}
impl AptabaseClient {
pub(crate) fn new(config: &Config, app_version: String) -> Self {
let sys_info = sys::get_info();
let is_enabled = !config.app_key.is_empty();
let dispatcher = Arc::new(EventDispatcher::new(config, &sys_info));
Self {
is_enabled,
dispatcher,
session: SyncMutex::new(TrackingSession::new(config.session_id.clone())),
app_version,
sys_info,
}
}
pub(crate) fn start_polling(&self, interval: Duration) {
let dispatcher = self.dispatcher.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
dispatcher.flush().await;
}
});
}
pub(crate) fn eval_session_id(&self) -> String {
let mut session = self.session.lock().expect("could not lock events");
let now = OffsetDateTime::now_utc();
if (now - session.last_touch_ts) > SESSION_TIMEOUT {
*session = TrackingSession::new(None);
} else {
session.last_touch_ts = now;
}
session.id.clone()
}
pub fn track_event(&self, name: &str, props: Option<Value>) -> Result<(), String> {
if !self.is_enabled {
return Ok(());
}
if let Some(props) = &props
&& !matches!(props, Value::Object(_))
{
return Err(
"props must be `None` or the `Object` variation of `serde_json::Value`".to_owned(),
);
}
let ev = json!({
"timestamp": OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
"sessionId": self.eval_session_id(),
"eventName": name,
"systemProps": {
"isDebug": self.sys_info.is_debug,
"osName": self.sys_info.os_name,
"osVersion": self.sys_info.os_version,
"locale": self.sys_info.locale,
"appVersion": self.app_version,
"sdkVersion": concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION"))
},
"props": props
});
self.dispatcher.enqueue(ev);
Ok(())
}
pub async fn flush(&self) {
self.dispatcher.flush().await;
}
pub(crate) fn flush_blocking(&self) {
futures::executor::block_on(async {
self.flush().await;
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_session_id_is_not_empty() {
let session_id = new_session_id();
assert!(!session_id.is_empty());
}
}