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, 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}
23
24impl Event {
25    /// Capture a new identified [`Event`]. Unless you have a distinct ID you can
26    /// associate with a user, you probably want to use [`new_anon`] instead.
27    pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
28        Self {
29            event: event.into(),
30            distinct_id: distinct_id.into(),
31            properties: HashMap::new(),
32            groups: HashMap::new(),
33            timestamp: None,
34        }
35    }
36
37    /// Capture a new anonymous event.
38    /// See https://posthog.com/docs/data/anonymous-vs-identified-events#how-to-capture-anonymous-events
39    pub fn new_anon<S: Into<String>>(event: S) -> Self {
40        let mut res = Self {
41            event: event.into(),
42            distinct_id: Uuid::now_v7().to_string(),
43            properties: HashMap::new(),
44            groups: HashMap::new(),
45            timestamp: None,
46        };
47        res.insert_prop("$process_person_profile", false)
48            .expect("bools are safe for serde");
49        res
50    }
51
52    /// Add a property to the event
53    ///
54    /// Errors if `prop` fails to serialize
55    pub fn insert_prop<K: Into<String>, P: Serialize>(
56        &mut self,
57        key: K,
58        prop: P,
59    ) -> Result<(), Error> {
60        let as_json =
61            serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?;
62        let _ = self.properties.insert(key.into(), as_json);
63        Ok(())
64    }
65
66    /// Capture this as a group event. See https://posthog.com/docs/product-analytics/group-analytics#how-to-capture-group-events
67    /// Note that group events cannot be personless, and will be automatically upgraded to include person profile processing if
68    /// they were anonymous. This might lead to "empty" person profiles being created.
69    pub fn add_group(&mut self, group_name: &str, group_id: &str) {
70        // You cannot disable person profile processing for groups
71        self.insert_prop("$process_person_profile", true)
72            .expect("bools are safe for serde");
73        self.groups.insert(group_name.into(), group_id.into());
74    }
75
76    /// Set the event timestamp, for events that happened in the past.
77    ///
78    /// Errors if the timestamp is in the future.
79    pub fn set_timestamp<Tz>(&mut self, timestamp: DateTime<Tz>) -> Result<(), Error>
80    where
81        Tz: TimeZone,
82    {
83        if timestamp > Utc::now() + Duration::seconds(1) {
84            return Err(Error::InvalidTimestamp(String::from(
85                "Events cannot occur in the future",
86            )));
87        }
88        self.timestamp = Some(timestamp.naive_utc());
89        Ok(())
90    }
91}
92
93// This exists so that the client doesn't have to specify the API key over and over
94#[derive(Serialize)]
95pub struct InnerEvent {
96    api_key: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    uuid: Option<Uuid>,
99    event: String,
100    #[serde(rename = "$distinct_id")]
101    distinct_id: String,
102    properties: HashMap<String, serde_json::Value>,
103    timestamp: Option<NaiveDateTime>,
104}
105
106impl InnerEvent {
107    pub fn new(event: Event, api_key: String) -> Self {
108        Self::new_with_uuid(event, api_key, None)
109    }
110
111    pub fn new_with_uuid(event: Event, api_key: String, uuid: Option<Uuid>) -> Self {
112        let mut properties = event.properties;
113
114        // Add $lib_name and $lib_version to the properties
115        properties.insert(
116            "$lib".into(),
117            serde_json::Value::String("posthog-rs".into()),
118        );
119
120        let version_str = env!("CARGO_PKG_VERSION");
121        properties.insert(
122            "$lib_version".into(),
123            serde_json::Value::String(version_str.into()),
124        );
125
126        if let Ok(version) = version_str.parse::<Version>() {
127            properties.insert(
128                "$lib_version__major".into(),
129                serde_json::Value::Number(version.major.into()),
130            );
131            properties.insert(
132                "$lib_version__minor".into(),
133                serde_json::Value::Number(version.minor.into()),
134            );
135            properties.insert(
136                "$lib_version__patch".into(),
137                serde_json::Value::Number(version.patch.into()),
138            );
139        }
140
141        if !event.groups.is_empty() {
142            properties.insert(
143                "$groups".into(),
144                serde_json::Value::Object(
145                    event
146                        .groups
147                        .into_iter()
148                        .map(|(k, v)| (k, serde_json::Value::String(v)))
149                        .collect(),
150                ),
151            );
152        }
153
154        Self {
155            api_key,
156            uuid,
157            event: event.event,
158            distinct_id: event.distinct_id,
159            properties,
160            timestamp: event.timestamp,
161        }
162    }
163}
164
165#[cfg(test)]
166pub mod tests {
167    use crate::{event::InnerEvent, Event};
168
169    #[test]
170    fn inner_event_adds_lib_properties_correctly() {
171        // Arrange
172        let mut event = Event::new("unit test event", "1234");
173        event.insert_prop("key1", "value1").unwrap();
174        let api_key = "test_api_key".to_string();
175
176        // Act
177        let inner_event = InnerEvent::new(event, api_key);
178
179        // Assert
180        let props = &inner_event.properties;
181        assert_eq!(
182            props.get("$lib"),
183            Some(&serde_json::Value::String("posthog-rs".to_string()))
184        );
185    }
186}
187
188#[cfg(test)]
189mod test {
190    use std::time::Duration;
191
192    use chrono::{DateTime, Utc};
193
194    use super::Event;
195
196    #[test]
197    fn test_timestamp_is_correctly_set() {
198        let mut event = Event::new_anon("test");
199        let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
200        event.set_timestamp(ts).expect("Date is not in the future");
201        let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
202        assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
203    }
204
205    #[test]
206    fn test_timestamp_is_correctly_set_with_future_date() {
207        let mut event = Event::new_anon("test");
208        let ts = Utc::now() + Duration::from_secs(60);
209        event
210            .set_timestamp(ts)
211            .expect_err("Date is in the future, should be rejected");
212
213        assert!(event.timestamp.is_none())
214    }
215}