fluidattacks-tracks 0.0.1

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 {
    /// Create a new event with required fields. Optional fields default to `None`.
    pub fn new(
        action: Action,
        author: String,
        date: DateTime<Utc>,
        mechanism: Mechanism,
        metadata: serde_json::Value,
        object: String,
        object_id: String,
    ) -> Self {
        Event {
            action,
            author,
            date,
            mechanism,
            metadata,
            object,
            object_id,
            author_anonymous: None,
            author_ip: None,
            author_role: None,
            author_user_agent: None,
            session_id: None,
        }
    }
}

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

impl EventResource {
    pub(crate) fn new(base_url: String, http: Client, retry_attempts: u32) -> Self {
        EventResource {
            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 || {
            for attempt in 0..retry_attempts {
                match http.post(&url).json(&event).send() {
                    Ok(resp) if resp.status().is_success() => return,
                    _ => {
                        if attempt + 1 < retry_attempts {
                            thread::sleep(Duration::from_millis(100 * 2u64.pow(attempt)));
                        }
                    }
                }
            }
        });
        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();
        }
    }
}