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