Skip to main content

posthog_rs/
event.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
4use semver::Version;
5use serde::Serialize;
6use uuid::Uuid;
7
8use crate::Error;
9
10/// An [`Event`] represents an interaction a user has with your app or
11/// website. Examples include button clicks, pageviews, query completions, and signups.
12/// See the [PostHog documentation](https://posthog.com/docs/data/events)
13/// for a detailed explanation of PostHog Events.
14#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
15pub struct Event {
16    event: String,
17    #[serde(rename = "$distinct_id")]
18    distinct_id: String,
19    properties: HashMap<String, serde_json::Value>,
20    groups: HashMap<String, String>,
21    timestamp: Option<NaiveDateTime>,
22    uuid: Uuid,
23}
24
25impl Event {
26    /// Capture a new identified [`Event`]. Unless you have a distinct ID you can
27    /// associate with a user, you probably want to use [`new_anon`] instead.
28    pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
29        Self {
30            event: event.into(),
31            distinct_id: distinct_id.into(),
32            properties: HashMap::new(),
33            groups: HashMap::new(),
34            timestamp: None,
35            uuid: Uuid::now_v7(),
36        }
37    }
38
39    /// Capture a new anonymous event.
40    /// See https://posthog.com/docs/data/anonymous-vs-identified-events#how-to-capture-anonymous-events
41    pub fn new_anon<S: Into<String>>(event: S) -> Self {
42        let mut res = Self {
43            event: event.into(),
44            distinct_id: Uuid::now_v7().to_string(),
45            properties: HashMap::new(),
46            groups: HashMap::new(),
47            timestamp: None,
48            uuid: Uuid::now_v7(),
49        };
50        res.insert_prop("$process_person_profile", false)
51            .expect("bools are safe for serde");
52        res
53    }
54
55    /// Add a property to the event
56    ///
57    /// Errors if `prop` fails to serialize
58    pub fn insert_prop<K: Into<String>, P: Serialize>(
59        &mut self,
60        key: K,
61        prop: P,
62    ) -> Result<(), Error> {
63        let as_json =
64            serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?;
65        let _ = self.properties.insert(key.into(), as_json);
66        Ok(())
67    }
68
69    /// Capture this as a group event. See https://posthog.com/docs/product-analytics/group-analytics#how-to-capture-group-events
70    /// Note that group events cannot be personless, and will be automatically upgraded to include person profile processing if
71    /// they were anonymous. This might lead to "empty" person profiles being created.
72    pub fn add_group(&mut self, group_name: &str, group_id: &str) {
73        // You cannot disable person profile processing for groups
74        self.insert_prop("$process_person_profile", true)
75            .expect("bools are safe for serde");
76        self.groups.insert(group_name.into(), group_id.into());
77    }
78
79    /// Set the event timestamp, for events that happened in the past.
80    ///
81    /// Errors if the timestamp is in the future.
82    pub fn set_timestamp<Tz>(&mut self, timestamp: DateTime<Tz>) -> Result<(), Error>
83    where
84        Tz: TimeZone,
85    {
86        if timestamp > Utc::now() + Duration::seconds(1) {
87            return Err(Error::InvalidTimestamp(String::from(
88                "Events cannot occur in the future",
89            )));
90        }
91        self.timestamp = Some(timestamp.naive_utc());
92        Ok(())
93    }
94
95    /// Override the auto-generated UUID for this event. Useful for
96    /// deduplication when re-importing historical data.
97    pub fn set_uuid(&mut self, uuid: Uuid) {
98        self.uuid = uuid;
99    }
100}
101
102/// Wrapper for the `/batch/` endpoint that includes the API key and options
103/// alongside the event array.
104#[derive(Serialize)]
105pub struct BatchRequest {
106    pub api_key: String,
107    pub historical_migration: bool,
108    pub batch: Vec<InnerEvent>,
109}
110
111// This exists so that the client doesn't have to specify the API key over and over
112#[derive(Serialize)]
113pub struct InnerEvent {
114    api_key: String,
115    uuid: Uuid,
116    event: String,
117    #[serde(rename = "$distinct_id")]
118    distinct_id: String,
119    properties: HashMap<String, serde_json::Value>,
120    timestamp: Option<NaiveDateTime>,
121}
122
123impl InnerEvent {
124    pub fn new(event: Event, api_key: String) -> Self {
125        let uuid = event.uuid;
126        let mut properties = event.properties;
127
128        // Set $lib and $lib_version if not already present, so callers
129        // forwarding events from other SDKs can preserve the original values.
130        if !properties.contains_key("$lib") {
131            properties.insert(
132                "$lib".into(),
133                serde_json::Value::String("posthog-rs".into()),
134            );
135        }
136
137        let version_str = env!("CARGO_PKG_VERSION");
138        if !properties.contains_key("$lib_version") {
139            properties.insert(
140                "$lib_version".into(),
141                serde_json::Value::String(version_str.into()),
142            );
143        }
144
145        if !properties.contains_key("$lib_version__major") {
146            if let Ok(version) = version_str.parse::<Version>() {
147                properties.insert(
148                    "$lib_version__major".into(),
149                    serde_json::Value::Number(version.major.into()),
150                );
151                properties.insert(
152                    "$lib_version__minor".into(),
153                    serde_json::Value::Number(version.minor.into()),
154                );
155                properties.insert(
156                    "$lib_version__patch".into(),
157                    serde_json::Value::Number(version.patch.into()),
158                );
159            }
160        }
161
162        if !event.groups.is_empty() {
163            properties.insert(
164                "$groups".into(),
165                serde_json::Value::Object(
166                    event
167                        .groups
168                        .into_iter()
169                        .map(|(k, v)| (k, serde_json::Value::String(v)))
170                        .collect(),
171                ),
172            );
173        }
174
175        Self {
176            api_key,
177            uuid,
178            event: event.event,
179            distinct_id: event.distinct_id,
180            properties,
181            timestamp: event.timestamp,
182        }
183    }
184}
185
186#[cfg(test)]
187pub mod tests {
188    use uuid::Uuid;
189
190    use crate::{event::InnerEvent, Event};
191
192    #[test]
193    fn inner_event_adds_lib_properties_correctly() {
194        // Arrange
195        let mut event = Event::new("unit test event", "1234");
196        event.insert_prop("key1", "value1").unwrap();
197        let api_key = "test_api_key".to_string();
198
199        // Act
200        let inner_event = InnerEvent::new(event, api_key);
201
202        // Assert
203        let props = &inner_event.properties;
204        assert_eq!(
205            props.get("$lib"),
206            Some(&serde_json::Value::String("posthog-rs".to_string()))
207        );
208    }
209
210    #[test]
211    fn inner_event_includes_auto_generated_uuid() {
212        let event = Event::new("test", "user1");
213
214        let inner = InnerEvent::new(event, "key".to_string());
215        let json = serde_json::to_value(&inner).unwrap();
216
217        let uuid_str = json["uuid"].as_str().expect("uuid should be present");
218        Uuid::parse_str(uuid_str).expect("uuid should be valid");
219    }
220
221    #[test]
222    fn inner_event_preserves_overridden_uuid() {
223        let uuid = Uuid::now_v7();
224        let mut event = Event::new("test", "user1");
225        event.set_uuid(uuid);
226
227        let inner = InnerEvent::new(event, "key".to_string());
228        let json = serde_json::to_value(&inner).unwrap();
229
230        assert_eq!(json["uuid"], uuid.to_string());
231    }
232
233    #[test]
234    fn inner_event_preserves_existing_lib_properties() {
235        let mut event = Event::new("forwarded event", "user1");
236        event.insert_prop("$lib", "posthog-js").unwrap();
237        event.insert_prop("$lib_version", "1.42.0").unwrap();
238        event.insert_prop("$lib_version__major", 1u64).unwrap();
239
240        let inner = InnerEvent::new(event, "key".to_string());
241        let props = &inner.properties;
242
243        assert_eq!(
244            props.get("$lib"),
245            Some(&serde_json::Value::String("posthog-js".to_string()))
246        );
247        assert_eq!(
248            props.get("$lib_version"),
249            Some(&serde_json::Value::String("1.42.0".to_string()))
250        );
251        assert_eq!(
252            props.get("$lib_version__major"),
253            Some(&serde_json::Value::Number(1u64.into()))
254        );
255    }
256}
257
258#[cfg(test)]
259mod test {
260    use std::time::Duration;
261
262    use chrono::{DateTime, Utc};
263
264    use super::Event;
265
266    #[test]
267    fn test_timestamp_is_correctly_set() {
268        let mut event = Event::new_anon("test");
269        let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
270        event.set_timestamp(ts).expect("Date is not in the future");
271        let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
272        assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
273    }
274
275    #[test]
276    fn test_timestamp_is_correctly_set_with_future_date() {
277        let mut event = Event::new_anon("test");
278        let ts = Utc::now() + Duration::from_secs(60);
279        event
280            .set_timestamp(ts)
281            .expect_err("Date is in the future, should be rejected");
282
283        assert!(event.timestamp.is_none())
284    }
285}