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::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::client::CRATE_VERSION;
9use crate::feature_flag_evaluations::FeatureFlagEvaluations;
10use crate::Error;
11
12/// Per-event V1 capture options. Known fields are typed; unknown keys go into
13/// `extra` via `#[serde(flatten)]` for forward compatibility with new backend
14/// options without requiring an SDK update.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
16pub struct EventOptions {
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub cookieless_mode: Option<bool>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub disable_skew_correction: Option<bool>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub product_tour_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub process_person_profile: Option<bool>,
25
26    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
27    pub extra: HashMap<String, serde_json::Value>,
28}
29
30/// An [`Event`] represents an interaction a user has with your app or
31/// website. Examples include button clicks, pageviews, query completions, and signups.
32/// See the [PostHog documentation](https://posthog.com/docs/data/events)
33/// for a detailed explanation of PostHog Events.
34#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
35pub struct Event {
36    event: String,
37    distinct_id: String,
38    properties: HashMap<String, serde_json::Value>,
39    groups: HashMap<String, String>,
40    timestamp: Option<NaiveDateTime>,
41    uuid: Uuid,
42    #[serde(skip)]
43    options: EventOptions,
44}
45
46impl Event {
47    /// Create a new identified [`Event`]. Unless you have a distinct ID you can
48    /// associate with a user, you probably want to use [`Event::new_anon`]
49    /// instead.
50    ///
51    /// # Parameters
52    ///
53    /// - `event`: Event name, such as `"user_signed_up"`.
54    /// - `distinct_id`: Stable user or account identifier. For backend events,
55    ///   use the same distinct ID your frontend passes to `posthog.identify()`.
56    pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
57        Self {
58            event: event.into(),
59            distinct_id: distinct_id.into(),
60            properties: HashMap::new(),
61            groups: HashMap::new(),
62            timestamp: None,
63            uuid: Uuid::now_v7(),
64            options: EventOptions::default(),
65        }
66    }
67
68    /// Create a new anonymous event.
69    ///
70    /// See <https://posthog.com/docs/data/anonymous-vs-identified-events#how-to-capture-anonymous-events>.
71    ///
72    /// # Parameters
73    ///
74    /// - `event`: Event name.
75    ///
76    /// # Remarks
77    ///
78    /// Generates a random distinct ID and sets `$process_person_profile` to
79    /// `false` so PostHog does not create a person profile for the event.
80    pub fn new_anon<S: Into<String>>(event: S) -> Self {
81        Self {
82            event: event.into(),
83            distinct_id: Uuid::now_v7().to_string(),
84            properties: HashMap::new(),
85            groups: HashMap::new(),
86            timestamp: None,
87            uuid: Uuid::now_v7(),
88            options: EventOptions {
89                process_person_profile: Some(false),
90                ..EventOptions::default()
91            },
92        }
93    }
94
95    /// Add a property to the event.
96    ///
97    /// # Parameters
98    ///
99    /// - `key`: Property name.
100    /// - `prop`: Any value that can be serialized to JSON.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`Error::Serialization`] if `prop` cannot be serialized.
105    pub fn insert_prop<K: Into<String>, P: Serialize>(
106        &mut self,
107        key: K,
108        prop: P,
109    ) -> Result<(), Error> {
110        let as_json =
111            serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?;
112        let _ = self.properties.insert(key.into(), as_json);
113        Ok(())
114    }
115
116    /// Remove a property from the event and return its previous value, if any.
117    pub fn remove_prop(&mut self, key: &str) -> Option<serde_json::Value> {
118        self.properties.remove(key)
119    }
120
121    /// Capture this as a group event.
122    ///
123    /// See <https://posthog.com/docs/product-analytics/group-analytics#how-to-capture-group-events>.
124    ///
125    /// # Parameters
126    ///
127    /// - `group_name`: Group type, such as `"company"`.
128    /// - `group_id`: Stable identifier for the group.
129    ///
130    /// # Remarks
131    ///
132    /// Group events cannot be personless, and will be automatically upgraded to
133    /// include person profile processing if they were anonymous. This might lead
134    /// to "empty" person profiles being created.
135    pub fn add_group(&mut self, group_name: &str, group_id: &str) {
136        self.options.process_person_profile = Some(true);
137        self.groups.insert(group_name.into(), group_id.into());
138    }
139
140    /// Set the event timestamp, for events that happened in the past.
141    ///
142    /// # Parameters
143    ///
144    /// - `timestamp`: Timestamp to send with the event. It is converted to UTC
145    ///   before serialization.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`Error::InvalidTimestamp`] if the timestamp is in the future.
150    pub fn set_timestamp<Tz>(&mut self, timestamp: DateTime<Tz>) -> Result<(), Error>
151    where
152        Tz: TimeZone,
153    {
154        if timestamp > Utc::now() + Duration::seconds(1) {
155            return Err(Error::InvalidTimestamp(String::from(
156                "Events cannot occur in the future",
157            )));
158        }
159        self.timestamp = Some(timestamp.naive_utc());
160        Ok(())
161    }
162
163    /// Override the auto-generated UUID for this event.
164    ///
165    /// Useful for deduplication when re-importing historical data.
166    pub fn set_uuid(&mut self, uuid: Uuid) {
167        self.uuid = uuid;
168    }
169
170    /// Attach the flag state captured by a [`FeatureFlagEvaluations`] snapshot
171    /// to this event.
172    ///
173    /// Adds `$feature/<key>` for every evaluated flag plus a sorted
174    /// `$active_feature_flags` list of enabled keys, mirroring what
175    /// `send_feature_flags` would otherwise fetch — but without making an
176    /// extra `/flags` request.
177    ///
178    /// # Returns
179    ///
180    /// Returns `self` so calls can be chained before capture.
181    pub fn with_flags(&mut self, flags: &FeatureFlagEvaluations) -> &mut Self {
182        for (key, value) in flags.event_properties() {
183            self.properties.insert(key, value);
184        }
185        self
186    }
187
188    /// Set or reset a V1 capture option by key. Known keys route to typed
189    /// fields; unknown keys go into the forward-compatible overflow map.
190    #[cfg(feature = "capture-v1")]
191    pub fn set_option<V: Serialize>(&mut self, key: &str, value: V) -> Result<(), Error> {
192        let val = serde_json::to_value(value).map_err(|e| Error::Serialization(e.to_string()))?;
193        match key {
194            "cookieless_mode" => self.options.cookieless_mode = val.as_bool(),
195            "disable_skew_correction" => self.options.disable_skew_correction = val.as_bool(),
196            "process_person_profile" => self.options.process_person_profile = val.as_bool(),
197            "product_tour_id" => self.options.product_tour_id = val.as_str().map(|s| s.to_string()),
198            _ => {
199                self.options.extra.insert(key.to_owned(), val);
200            }
201        }
202        Ok(())
203    }
204
205    /// Access the event options.
206    #[cfg(feature = "capture-v1")]
207    pub fn options(&self) -> &EventOptions {
208        &self.options
209    }
210
211    /// Return the event name.
212    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
213    pub fn event_name(&self) -> &str {
214        &self.event
215    }
216
217    /// Return the event distinct ID.
218    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
219    pub fn distinct_id(&self) -> &str {
220        &self.distinct_id
221    }
222
223    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
224    pub(crate) fn uuid(&self) -> Uuid {
225        self.uuid
226    }
227
228    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
229    pub(crate) fn timestamp(&self) -> Option<NaiveDateTime> {
230        self.timestamp
231    }
232
233    /// Return the event properties.
234    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
235    pub fn properties(&self) -> &HashMap<String, serde_json::Value> {
236        &self.properties
237    }
238
239    /// Insert a default property only if the caller hasn't already set it.
240    ///
241    /// This gives caller-wins semantics: SDK-level defaults (like `$is_server`)
242    /// are injected without overriding an explicit value the user placed on the
243    /// event before calling `capture()`.
244    pub(crate) fn insert_prop_default<K: Into<String>>(
245        &mut self,
246        key: K,
247        value: serde_json::Value,
248    ) {
249        self.properties.entry(key.into()).or_insert(value);
250    }
251
252    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
253    pub(crate) fn groups(&self) -> &HashMap<String, String> {
254        &self.groups
255    }
256
257    /// Translate `options` and `groups` into V0 properties and inject SDK
258    /// metadata. Call this before constructing [`InnerEvent`] so that the
259    /// wire payload matches what the V0 `/capture` and `/batch` endpoints expect.
260    #[cfg_attr(feature = "capture-v1", allow(dead_code))]
261    pub(crate) fn prepare_for_v0(&mut self) {
262        if !self.properties.contains_key("$lib") {
263            self.properties.insert(
264                "$lib".into(),
265                serde_json::Value::String("posthog-rs".into()),
266            );
267        }
268
269        let version_str = CRATE_VERSION;
270        if !self.properties.contains_key("$lib_version") {
271            self.properties.insert(
272                "$lib_version".into(),
273                serde_json::Value::String(version_str.into()),
274            );
275        }
276
277        if !self.properties.contains_key("$lib_version__major") {
278            if let Ok(version) = version_str.parse::<Version>() {
279                self.properties.insert(
280                    "$lib_version__major".into(),
281                    serde_json::Value::Number(version.major.into()),
282                );
283                self.properties.insert(
284                    "$lib_version__minor".into(),
285                    serde_json::Value::Number(version.minor.into()),
286                );
287                self.properties.insert(
288                    "$lib_version__patch".into(),
289                    serde_json::Value::Number(version.patch.into()),
290                );
291            }
292        }
293
294        if let Some(ppp) = self.options.process_person_profile {
295            self.properties
296                .entry("$process_person_profile".into())
297                .or_insert_with(|| serde_json::Value::Bool(ppp));
298        }
299
300        if !self.groups.is_empty() {
301            self.properties.insert(
302                "$groups".into(),
303                serde_json::Value::Object(
304                    self.groups
305                        .iter()
306                        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
307                        .collect(),
308                ),
309            );
310        }
311    }
312}
313
314/// Wrapper for the `/batch/` endpoint that includes the API key and options
315/// alongside the event array.
316#[cfg(not(feature = "capture-v1"))]
317#[derive(Serialize)]
318pub struct BatchRequest {
319    pub api_key: String,
320    pub historical_migration: bool,
321    pub batch: Vec<InnerEvent>,
322}
323
324// With `capture-v1` enabled nothing outside tests builds the V0 wire format.
325#[cfg_attr(feature = "capture-v1", allow(dead_code))]
326#[derive(Serialize)]
327pub struct InnerEvent {
328    #[serde(skip_serializing_if = "Option::is_none")]
329    api_key: Option<String>,
330    uuid: Uuid,
331    event: String,
332    distinct_id: String,
333    properties: HashMap<String, serde_json::Value>,
334    timestamp: Option<NaiveDateTime>,
335}
336
337impl InnerEvent {
338    /// Construct a V0 single-event wire event. Expects that
339    /// [`Event::prepare_for_v0`] has already been called so properties are fully
340    /// decorated.
341    #[cfg_attr(feature = "capture-v1", allow(dead_code))]
342    pub fn new(event: Event, api_key: String) -> Self {
343        Self::from_event(event, Some(api_key))
344    }
345
346    /// Construct a V0 batch wire event. The `/batch/` root `api_key` has
347    /// precedence on the backend, so per-event keys are intentionally omitted.
348    #[cfg(not(feature = "capture-v1"))]
349    pub(crate) fn new_for_batch(event: Event) -> Self {
350        Self::from_event(event, None)
351    }
352
353    fn from_event(event: Event, api_key: Option<String>) -> Self {
354        Self {
355            api_key,
356            uuid: event.uuid,
357            event: event.event,
358            distinct_id: event.distinct_id,
359            properties: event.properties,
360            timestamp: event.timestamp,
361        }
362    }
363}
364
365#[cfg(test)]
366pub mod tests {
367    use uuid::Uuid;
368
369    use crate::{event::InnerEvent, Event};
370
371    /// Helper: prepares an event for V0 and constructs the InnerEvent.
372    fn build_v0(mut event: Event) -> InnerEvent {
373        event.prepare_for_v0();
374        InnerEvent::new(event, "test_api_key".to_string())
375    }
376
377    #[cfg(not(feature = "capture-v1"))]
378    fn build_v0_batch_event(mut event: Event) -> InnerEvent {
379        event.prepare_for_v0();
380        InnerEvent::new_for_batch(event)
381    }
382
383    #[test]
384    fn v0_adds_lib_properties() {
385        let mut event = Event::new("unit test event", "1234");
386        event.insert_prop("key1", "value1").unwrap();
387
388        let inner = build_v0(event);
389        assert_eq!(
390            inner.properties.get("$lib"),
391            Some(&serde_json::Value::String("posthog-rs".to_string()))
392        );
393    }
394
395    #[test]
396    fn v0_serializes_distinct_id_at_root() {
397        let inner = build_v0(Event::new("test", "user1"));
398        let json = serde_json::to_value(&inner).unwrap();
399
400        // Canonical field at the event root; the legacy `$distinct_id` spelling
401        // (only tolerated by capture via a serde alias) must not be emitted.
402        assert_eq!(json["distinct_id"], "user1");
403        assert!(json.get("$distinct_id").is_none());
404    }
405
406    #[cfg(not(feature = "capture-v1"))]
407    #[test]
408    fn v0_batch_serializes_distinct_id_at_root() {
409        use crate::event::BatchRequest;
410
411        let batch = BatchRequest {
412            api_key: "test_api_key".to_string(),
413            historical_migration: false,
414            batch: vec![
415                build_v0_batch_event(Event::new("e1", "user1")),
416                build_v0_batch_event(Event::new("e2", "user2")),
417            ],
418        };
419        let json = serde_json::to_value(&batch).unwrap();
420
421        assert_eq!(json["api_key"], "test_api_key");
422
423        let events = json["batch"].as_array().expect("batch is an array");
424        for (event, expected_id) in events.iter().zip(["user1", "user2"]) {
425            assert_eq!(event["distinct_id"], expected_id);
426            assert!(event.get("$distinct_id").is_none());
427            assert!(event.get("api_key").is_none());
428        }
429    }
430
431    #[test]
432    fn v0_includes_auto_generated_uuid() {
433        let event = Event::new("test", "user1");
434        let inner = build_v0(event);
435        let json = serde_json::to_value(&inner).unwrap();
436
437        let uuid_str = json["uuid"].as_str().expect("uuid should be present");
438        Uuid::parse_str(uuid_str).expect("uuid should be valid");
439    }
440
441    #[test]
442    fn v0_preserves_overridden_uuid() {
443        let uuid = Uuid::now_v7();
444        let mut event = Event::new("test", "user1");
445        event.set_uuid(uuid);
446
447        let inner = build_v0(event);
448        let json = serde_json::to_value(&inner).unwrap();
449        assert_eq!(json["uuid"], uuid.to_string());
450    }
451
452    #[test]
453    fn v0_preserves_existing_lib_properties() {
454        let mut event = Event::new("forwarded event", "user1");
455        event.insert_prop("$lib", "posthog-js").unwrap();
456        event.insert_prop("$lib_version", "1.42.0").unwrap();
457        event.insert_prop("$lib_version__major", 1u64).unwrap();
458
459        let inner = build_v0(event);
460        let props = &inner.properties;
461
462        assert_eq!(
463            props.get("$lib"),
464            Some(&serde_json::Value::String("posthog-js".to_string()))
465        );
466        assert_eq!(
467            props.get("$lib_version"),
468            Some(&serde_json::Value::String("1.42.0".to_string()))
469        );
470        assert_eq!(
471            props.get("$lib_version__major"),
472            Some(&serde_json::Value::Number(1u64.into()))
473        );
474    }
475
476    #[test]
477    fn v0_injects_process_person_profile_for_anon() {
478        let event = Event::new_anon("anon_test");
479        let inner = build_v0(event);
480        assert_eq!(
481            inner.properties.get("$process_person_profile"),
482            Some(&serde_json::Value::Bool(false))
483        );
484    }
485
486    #[test]
487    fn v0_injects_process_person_profile_for_group() {
488        let mut event = Event::new("test", "user1");
489        event.add_group("company", "acme");
490        let inner = build_v0(event);
491        assert_eq!(
492            inner.properties.get("$process_person_profile"),
493            Some(&serde_json::Value::Bool(true))
494        );
495    }
496
497    #[test]
498    fn v0_no_process_person_profile_when_unset() {
499        let event = Event::new("test", "user1");
500        let inner = build_v0(event);
501        assert!(!inner.properties.contains_key("$process_person_profile"));
502    }
503
504    #[test]
505    fn v0_user_property_wins_over_options_injection() {
506        let mut event = Event::new_anon("test");
507        event.insert_prop("$process_person_profile", true).unwrap();
508        let inner = build_v0(event);
509        assert_eq!(
510            inner.properties.get("$process_person_profile"),
511            Some(&serde_json::Value::Bool(true)),
512        );
513    }
514}
515
516#[cfg(test)]
517mod test {
518    use std::time::Duration;
519
520    use chrono::{DateTime, Utc};
521
522    use super::Event;
523
524    #[test]
525    fn test_timestamp_is_correctly_set() {
526        let mut event = Event::new_anon("test");
527        let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
528        event.set_timestamp(ts).expect("Date is not in the future");
529        let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
530        assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
531    }
532
533    #[test]
534    fn test_timestamp_is_correctly_set_with_future_date() {
535        let mut event = Event::new_anon("test");
536        let ts = Utc::now() + Duration::from_secs(60);
537        event
538            .set_timestamp(ts)
539            .expect_err("Date is in the future, should be rejected");
540
541        assert!(event.timestamp.is_none())
542    }
543}