Skip to main content

fluidattacks_tracks/resources/
event.rs

1//! Tracks event resource.
2
3use std::sync::Mutex;
4use std::thread::{self, JoinHandle};
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use reqwest::blocking::Client;
9use serde::Serialize;
10
11/// Action performed on an object.
12#[non_exhaustive]
13#[derive(Debug, Clone, Serialize)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum Action {
16    Create,
17    Read,
18    Update,
19    Delete,
20}
21
22/// Mechanism by which the event was triggered.
23#[non_exhaustive]
24#[derive(Debug, Clone, Serialize)]
25pub enum Mechanism {
26    #[serde(rename = "API")]
27    Api,
28    #[serde(rename = "BTS")]
29    Bts,
30    #[serde(rename = "DESKTOP")]
31    Desktop,
32    #[serde(rename = "EMAIL")]
33    Email,
34    #[serde(rename = "FIXES")]
35    Fixes,
36    #[serde(rename = "FORCES")]
37    Forces,
38    #[serde(rename = "JIRA")]
39    Jira,
40    #[serde(rename = "MCP")]
41    Mcp,
42    #[serde(rename = "MELTS")]
43    Melts,
44    #[serde(rename = "MESSAGING")]
45    Messaging,
46    #[serde(rename = "MIGRATION")]
47    Migration,
48    #[serde(rename = "RETRIEVES")]
49    Retrieves,
50    #[serde(rename = "SCHEDULER")]
51    Scheduler,
52    #[serde(rename = "SMELLS")]
53    Smells,
54    #[serde(rename = "TASK")]
55    Task,
56    #[serde(rename = "WEB")]
57    Web,
58}
59
60/// A Tracks event to publish.
61#[non_exhaustive]
62#[derive(Debug, Clone, Serialize)]
63pub struct Event {
64    pub action: Action,
65    pub author: String,
66    pub date: DateTime<Utc>,
67    pub mechanism: Mechanism,
68    /// Arbitrary JSON object with additional event context.
69    ///
70    /// Must be a JSON object (i.e. `serde_json::json!({ "key": "value" })`).
71    /// Passing a non-object value (array, string, number, …) will cause the
72    /// event to be silently dropped when [`EventResource::create`] is called.
73    pub metadata: serde_json::Value,
74    pub object: String,
75    pub object_id: String,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub author_anonymous: Option<bool>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub author_ip: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub author_role: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub author_user_agent: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub session_id: Option<String>,
86}
87
88impl Event {
89    /// Start building an event. `date` defaults to [`Utc::now`] and `metadata`
90    /// to an empty JSON object; override them with [`EventBuilder::date`] /
91    /// [`EventBuilder::metadata`].
92    pub fn builder(
93        action: Action,
94        author: String,
95        mechanism: Mechanism,
96        object: String,
97        object_id: String,
98    ) -> EventBuilder {
99        EventBuilder {
100            event: Self {
101                action,
102                author,
103                date: Utc::now(),
104                mechanism,
105                metadata: serde_json::Value::Object(serde_json::Map::new()),
106                object,
107                object_id,
108                author_anonymous: None,
109                author_ip: None,
110                author_role: None,
111                author_user_agent: None,
112                session_id: None,
113            },
114        }
115    }
116}
117
118/// Builder for [`Event`].
119pub struct EventBuilder {
120    event: Event,
121}
122
123impl EventBuilder {
124    #[must_use]
125    pub const fn date(mut self, date: DateTime<Utc>) -> Self {
126        self.event.date = date;
127        self
128    }
129    #[must_use]
130    pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
131        self.event.metadata = metadata;
132        self
133    }
134
135    #[must_use]
136    pub const fn author_anonymous(mut self, value: bool) -> Self {
137        self.event.author_anonymous = Some(value);
138        self
139    }
140
141    #[must_use]
142    pub fn author_ip(mut self, value: String) -> Self {
143        self.event.author_ip = Some(value);
144        self
145    }
146
147    #[must_use]
148    pub fn author_role(mut self, value: String) -> Self {
149        self.event.author_role = Some(value);
150        self
151    }
152
153    #[must_use]
154    pub fn author_user_agent(mut self, value: String) -> Self {
155        self.event.author_user_agent = Some(value);
156        self
157    }
158
159    #[must_use]
160    pub fn session_id(mut self, value: String) -> Self {
161        self.event.session_id = Some(value);
162        self
163    }
164
165    pub fn build(self) -> Event {
166        self.event
167    }
168}
169
170fn send_with_retry(http: &Client, url: &str, event: &Event, retry_attempts: u32) {
171    for attempt in 0..retry_attempts {
172        let ok = http
173            .post(url)
174            .json(event)
175            .send()
176            .is_ok_and(|r| r.status().is_success());
177        if ok {
178            return;
179        }
180        if attempt.saturating_add(1) < retry_attempts {
181            let delay = 100u64.saturating_mul(2u64.saturating_pow(attempt));
182            thread::sleep(Duration::from_millis(delay));
183        }
184    }
185}
186
187/// Tracks event resource.
188pub struct EventResource {
189    base_url: String,
190    http: Client,
191    retry_attempts: u32,
192    pending: Mutex<Vec<JoinHandle<()>>>,
193}
194
195impl EventResource {
196    pub(crate) const fn new(base_url: String, http: Client, retry_attempts: u32) -> Self {
197        Self {
198            base_url,
199            http,
200            retry_attempts,
201            pending: Mutex::new(Vec::new()),
202        }
203    }
204
205    /// Publish an event fire-and-forget in a background thread.
206    ///
207    /// Errors are silently swallowed so that tracking failures never
208    /// interrupt the caller's normal flow. Call [`EventResource::flush`]
209    /// before process exit to ensure all in-flight events are delivered.
210    ///
211    /// # Panics
212    ///
213    /// Does not panic. If `event.metadata` is not a JSON object the event is
214    /// silently dropped.
215    pub fn create(&self, event: Event) {
216        if !event.metadata.is_object() {
217            return;
218        }
219        let url = format!("{}/event", self.base_url.trim_end_matches('/'));
220        let http = self.http.clone();
221        let retry_attempts = self.retry_attempts;
222        let handle = thread::spawn(move || {
223            send_with_retry(&http, &url, &event, retry_attempts);
224        });
225        if let Ok(mut pending) = self.pending.lock() {
226            pending.retain(|h| !h.is_finished());
227            pending.push(handle);
228        }
229    }
230
231    /// Block until all in-flight events have been sent or exhausted their retries.
232    ///
233    /// Call this before process exit to avoid dropping events that are still
234    /// being delivered in background threads.
235    pub fn flush(&self) {
236        let handles = self
237            .pending
238            .lock()
239            .map(|mut p| std::mem::take(&mut *p))
240            .unwrap_or_default();
241        for handle in handles {
242            let _ = handle.join();
243        }
244    }
245}