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