fluidattacks-tracks 0.1.0

Rust library for usage analytics
Documentation
//! Tracks event resource.

use std::sync::Mutex;
use std::thread::{self, JoinHandle};
use std::time::Duration;

use chrono::{DateTime, Utc};
use reqwest::blocking::Client;
use serde::Serialize;

/// Action performed on an object.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Action {
    Create,
    Read,
    Update,
    Delete,
}

/// Mechanism by which the event was triggered.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
pub enum Mechanism {
    #[serde(rename = "API")]
    Api,
    #[serde(rename = "BTS")]
    Bts,
    #[serde(rename = "DESKTOP")]
    Desktop,
    #[serde(rename = "EMAIL")]
    Email,
    #[serde(rename = "FIXES")]
    Fixes,
    #[serde(rename = "FORCES")]
    Forces,
    #[serde(rename = "JIRA")]
    Jira,
    #[serde(rename = "MCP")]
    Mcp,
    #[serde(rename = "MELTS")]
    Melts,
    #[serde(rename = "MESSAGING")]
    Messaging,
    #[serde(rename = "MIGRATION")]
    Migration,
    #[serde(rename = "RETRIEVES")]
    Retrieves,
    #[serde(rename = "SCHEDULER")]
    Scheduler,
    #[serde(rename = "SMELLS")]
    Smells,
    #[serde(rename = "TASK")]
    Task,
    #[serde(rename = "WEB")]
    Web,
}

/// A Tracks event to publish.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
pub struct Event {
    pub action: Action,
    pub author: String,
    pub date: DateTime<Utc>,
    pub mechanism: Mechanism,
    /// Arbitrary JSON object with additional event context.
    ///
    /// Must be a JSON object (i.e. `serde_json::json!({ "key": "value" })`).
    /// Passing a non-object value (array, string, number, …) will cause the
    /// event to be silently dropped when [`EventResource::create`] is called.
    pub metadata: serde_json::Value,
    pub object: String,
    pub object_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author_anonymous: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author_ip: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author_role: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author_user_agent: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
}

impl Event {
    /// Start building an event. `date` defaults to [`Utc::now`] and `metadata`
    /// to an empty JSON object; override them with [`EventBuilder::date`] /
    /// [`EventBuilder::metadata`].
    pub fn builder(
        action: Action,
        author: String,
        mechanism: Mechanism,
        object: String,
        object_id: String,
    ) -> EventBuilder {
        EventBuilder {
            event: Self {
                action,
                author,
                date: Utc::now(),
                mechanism,
                metadata: serde_json::Value::Object(serde_json::Map::new()),
                object,
                object_id,
                author_anonymous: None,
                author_ip: None,
                author_role: None,
                author_user_agent: None,
                session_id: None,
            },
        }
    }
}

/// Builder for [`Event`].
pub struct EventBuilder {
    event: Event,
}

impl EventBuilder {
    #[must_use]
    pub const fn date(mut self, date: DateTime<Utc>) -> Self {
        self.event.date = date;
        self
    }
    #[must_use]
    pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
        self.event.metadata = metadata;
        self
    }

    #[must_use]
    pub const fn author_anonymous(mut self, value: bool) -> Self {
        self.event.author_anonymous = Some(value);
        self
    }

    #[must_use]
    pub fn author_ip(mut self, value: String) -> Self {
        self.event.author_ip = Some(value);
        self
    }

    #[must_use]
    pub fn author_role(mut self, value: String) -> Self {
        self.event.author_role = Some(value);
        self
    }

    #[must_use]
    pub fn author_user_agent(mut self, value: String) -> Self {
        self.event.author_user_agent = Some(value);
        self
    }

    #[must_use]
    pub fn session_id(mut self, value: String) -> Self {
        self.event.session_id = Some(value);
        self
    }

    pub fn build(self) -> Event {
        self.event
    }
}

fn send_with_retry(http: &Client, url: &str, event: &Event, retry_attempts: u32) {
    for attempt in 0..retry_attempts {
        let ok = http
            .post(url)
            .json(event)
            .send()
            .is_ok_and(|r| r.status().is_success());
        if ok {
            return;
        }
        if attempt.saturating_add(1) < retry_attempts {
            let delay = 100u64.saturating_mul(2u64.saturating_pow(attempt));
            thread::sleep(Duration::from_millis(delay));
        }
    }
}

/// Tracks event resource.
pub struct EventResource {
    base_url: String,
    http: Client,
    retry_attempts: u32,
    pending: Mutex<Vec<JoinHandle<()>>>,
}

impl EventResource {
    pub(crate) const fn new(base_url: String, http: Client, retry_attempts: u32) -> Self {
        Self {
            base_url,
            http,
            retry_attempts,
            pending: Mutex::new(Vec::new()),
        }
    }

    /// Publish an event fire-and-forget in a background thread.
    ///
    /// Errors are silently swallowed so that tracking failures never
    /// interrupt the caller's normal flow. Call [`EventResource::flush`]
    /// before process exit to ensure all in-flight events are delivered.
    ///
    /// # Panics
    ///
    /// Does not panic. If `event.metadata` is not a JSON object the event is
    /// silently dropped.
    pub fn create(&self, event: Event) {
        if !event.metadata.is_object() {
            return;
        }
        let url = format!("{}/event", self.base_url.trim_end_matches('/'));
        let http = self.http.clone();
        let retry_attempts = self.retry_attempts;
        let handle = thread::spawn(move || {
            send_with_retry(&http, &url, &event, retry_attempts);
        });
        if let Ok(mut pending) = self.pending.lock() {
            pending.retain(|h| !h.is_finished());
            pending.push(handle);
        }
    }

    /// Block until all in-flight events have been sent or exhausted their retries.
    ///
    /// Call this before process exit to avoid dropping events that are still
    /// being delivered in background threads.
    pub fn flush(&self) {
        let handles = self
            .pending
            .lock()
            .map(|mut p| std::mem::take(&mut *p))
            .unwrap_or_default();
        for handle in handles {
            let _ = handle.join();
        }
    }
}