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