1use std::collections::HashMap;
2
3use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
4use semver::Version;
5use serde::Serialize;
6use tracing::warn;
7use uuid::Uuid;
8
9use crate::client::CRATE_VERSION;
10use crate::feature_flag_evaluations::FeatureFlagEvaluations;
11use crate::Error;
12
13pub(crate) const MINIMAL_FLAG_CALLED_EVENT_PROPERTIES: &[&str] = &[
25 "$feature_flag",
27 "$feature_flag_response",
28 "$feature_flag_has_experiment",
29 "$feature_flag_id",
31 "$feature_flag_version",
32 "$feature_flag_reason",
33 "$feature_flag_request_id",
34 "$feature_flag_evaluated_at",
35 "$feature_flag_error",
36 "locally_evaluated",
37 "$groups",
39 "$process_person_profile",
40 "$geoip_disable",
41 "$session_id",
43 "$window_id",
44 "$device_id",
45 "$lib",
46 "$lib_version",
47 "$is_server",
48 "$os",
50 "$os_version",
51];
52
53pub(crate) fn is_minimal_flag_called_property(key: &str) -> bool {
59 MINIMAL_FLAG_CALLED_EVENT_PROPERTIES.contains(&key)
60}
61
62#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
67pub struct Event {
68 event: String,
69 distinct_id: String,
70 properties: HashMap<String, serde_json::Value>,
71 groups: HashMap<String, String>,
72 timestamp: Option<NaiveDateTime>,
73 uuid: Uuid,
74 #[serde(skip)]
78 minimal_flag_called: bool,
79}
80
81impl Event {
82 pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
92 Self {
93 event: event.into(),
94 distinct_id: distinct_id.into(),
95 properties: HashMap::new(),
96 groups: HashMap::new(),
97 timestamp: None,
98 uuid: Uuid::now_v7(),
99 minimal_flag_called: false,
100 }
101 }
102
103 pub fn new_anon<S: Into<String>>(event: S) -> Self {
116 let mut properties = HashMap::new();
117 properties.insert(
118 crate::constants::PROCESS_PERSON_PROFILE_PROP.into(),
119 serde_json::Value::Bool(false),
120 );
121 Self {
122 event: event.into(),
123 distinct_id: Uuid::now_v7().to_string(),
124 properties,
125 groups: HashMap::new(),
126 timestamp: None,
127 uuid: Uuid::now_v7(),
128 minimal_flag_called: false,
129 }
130 }
131
132 pub fn insert_prop<K: Into<String>, P: Serialize>(
143 &mut self,
144 key: K,
145 prop: P,
146 ) -> Result<(), Error> {
147 let as_json =
148 serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?;
149 let _ = self.properties.insert(key.into(), as_json);
150 Ok(())
151 }
152
153 pub fn remove_prop(&mut self, key: &str) -> Option<serde_json::Value> {
155 self.properties.remove(key)
156 }
157
158 pub fn add_group(&mut self, group_name: &str, group_id: &str) {
173 self.properties.insert(
174 crate::constants::PROCESS_PERSON_PROFILE_PROP.into(),
175 serde_json::Value::Bool(true),
176 );
177 self.groups.insert(group_name.into(), group_id.into());
178 }
179
180 pub fn set_timestamp<Tz>(&mut self, timestamp: DateTime<Tz>) -> Result<(), Error>
191 where
192 Tz: TimeZone,
193 {
194 if timestamp > Utc::now() + Duration::seconds(1) {
195 return Err(Error::InvalidTimestamp(String::from(
196 "Events cannot occur in the future",
197 )));
198 }
199 self.timestamp = Some(timestamp.naive_utc());
200 Ok(())
201 }
202
203 pub(crate) fn alias(previous_id: String, distinct_id: String) -> Option<Self> {
217 if previous_id.trim().is_empty() || distinct_id.trim().is_empty() {
218 warn!("alias() called with a blank id, dropping the $create_alias event");
219 return None;
220 }
221
222 let mut properties = HashMap::new();
223 properties.insert(
224 "distinct_id".to_string(),
225 serde_json::Value::String(previous_id.clone()),
226 );
227 properties.insert("alias".to_string(), serde_json::Value::String(distinct_id));
228
229 Some(Self {
230 event: "$create_alias".to_string(),
231 distinct_id: previous_id,
232 properties,
233 groups: HashMap::new(),
234 timestamp: None,
235 uuid: Uuid::now_v7(),
236 minimal_flag_called: false,
237 })
238 }
239
240 pub(crate) fn group_identify<P: Serialize>(
253 group_type: String,
254 group_key: String,
255 properties: P,
256 ) -> Result<Option<Self>, Error> {
257 if group_type.trim().is_empty() {
258 warn!("group_identify() called with a blank group_type, dropping the $groupidentify event");
259 return Ok(None);
260 }
261 if group_key.trim().is_empty() {
262 warn!(
263 "group_identify() called with a blank group_key, dropping the $groupidentify event"
264 );
265 return Ok(None);
266 }
267
268 let group_set =
269 serde_json::to_value(properties).map_err(|e| Error::Serialization(e.to_string()))?;
270 if !group_set.is_object() {
271 return Err(Error::Serialization(format!(
272 "group_identify() properties must serialize to a JSON object, got {group_set}"
273 )));
274 }
275
276 let distinct_id = format!("${group_type}_{group_key}");
277 let mut props = HashMap::new();
278 props.insert(
279 "$group_type".to_string(),
280 serde_json::Value::String(group_type),
281 );
282 props.insert(
283 "$group_key".to_string(),
284 serde_json::Value::String(group_key),
285 );
286 props.insert("$group_set".to_string(), group_set);
287
288 Ok(Some(Self {
289 event: "$groupidentify".to_string(),
290 distinct_id,
291 properties: props,
292 groups: HashMap::new(),
293 timestamp: None,
294 uuid: Uuid::now_v7(),
295 minimal_flag_called: false,
296 }))
297 }
298
299 pub(crate) fn ensure_timestamp(&mut self, now: DateTime<Utc>) {
304 if self.timestamp.is_none() {
305 self.timestamp = Some(now.naive_utc());
306 }
307 }
308
309 pub fn set_uuid(&mut self, uuid: Uuid) {
313 self.uuid = uuid;
314 }
315
316 pub fn with_flags(&mut self, flags: &FeatureFlagEvaluations) -> &mut Self {
328 for (key, value) in flags.event_properties() {
329 self.properties.insert(key, value);
330 }
331 self
332 }
333
334 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
336 pub fn event_name(&self) -> &str {
337 &self.event
338 }
339
340 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
342 pub fn distinct_id(&self) -> &str {
343 &self.distinct_id
344 }
345
346 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
347 pub(crate) fn uuid(&self) -> Uuid {
348 self.uuid
349 }
350
351 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
352 pub(crate) fn timestamp(&self) -> Option<NaiveDateTime> {
353 self.timestamp
354 }
355
356 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
358 pub fn properties(&self) -> &HashMap<String, serde_json::Value> {
359 &self.properties
360 }
361
362 pub(crate) fn insert_prop_default<K: Into<String>>(
368 &mut self,
369 key: K,
370 value: serde_json::Value,
371 ) {
372 self.properties.entry(key.into()).or_insert(value);
373 }
374
375 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
376 pub(crate) fn groups(&self) -> &HashMap<String, String> {
377 &self.groups
378 }
379
380 pub(crate) fn mark_minimal_flag_called(&mut self) {
384 self.minimal_flag_called = true;
385 }
386
387 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
389 pub(crate) fn is_minimal_flag_called(&self) -> bool {
390 self.minimal_flag_called
391 }
392
393 #[cfg_attr(feature = "capture-v1", allow(dead_code))]
398 pub(crate) fn apply_minimal_flag_called_allowlist(&mut self) {
399 if self.minimal_flag_called {
400 self.properties
401 .retain(|key, _| is_minimal_flag_called_property(key));
402 }
403 }
404
405 #[cfg_attr(feature = "capture-v1", allow(dead_code))]
412 pub(crate) fn prepare_for_v0(&mut self) {
413 if !self.properties.contains_key("$lib") {
414 self.properties.insert(
415 "$lib".into(),
416 serde_json::Value::String("posthog-rs".into()),
417 );
418 }
419
420 let version_str = CRATE_VERSION;
421 if !self.properties.contains_key("$lib_version") {
422 self.properties.insert(
423 "$lib_version".into(),
424 serde_json::Value::String(version_str.into()),
425 );
426 }
427
428 if !self.properties.contains_key("$lib_version__major") {
429 if let Ok(version) = version_str.parse::<Version>() {
430 self.properties.insert(
431 "$lib_version__major".into(),
432 serde_json::Value::Number(version.major.into()),
433 );
434 self.properties.insert(
435 "$lib_version__minor".into(),
436 serde_json::Value::Number(version.minor.into()),
437 );
438 self.properties.insert(
439 "$lib_version__patch".into(),
440 serde_json::Value::Number(version.patch.into()),
441 );
442 }
443 }
444
445 if !self.groups.is_empty() {
446 self.properties.insert(
447 "$groups".into(),
448 serde_json::Value::Object(
449 self.groups
450 .iter()
451 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
452 .collect(),
453 ),
454 );
455 }
456 }
457}
458
459#[cfg(not(feature = "capture-v1"))]
462#[derive(Serialize)]
463pub struct BatchRequest {
464 pub api_key: String,
465 pub historical_migration: bool,
466 pub sent_at: String,
468 pub batch: Vec<InnerEvent>,
469}
470
471#[cfg_attr(feature = "capture-v1", allow(dead_code))]
473#[derive(Serialize)]
474pub struct InnerEvent {
475 #[serde(skip_serializing_if = "Option::is_none")]
476 api_key: Option<String>,
477 uuid: Uuid,
478 event: String,
479 distinct_id: String,
480 properties: HashMap<String, serde_json::Value>,
481 timestamp: Option<DateTime<Utc>>,
482}
483
484impl InnerEvent {
485 #[cfg(test)]
489 pub fn new(event: Event, api_key: String) -> Self {
490 Self::from_event(event, Some(api_key))
491 }
492
493 #[cfg(not(feature = "capture-v1"))]
496 pub(crate) fn new_for_batch(event: Event) -> Self {
497 Self::from_event(event, None)
498 }
499
500 #[cfg_attr(feature = "capture-v1", allow(dead_code))]
501 fn from_event(event: Event, api_key: Option<String>) -> Self {
502 Self {
503 api_key,
504 uuid: event.uuid,
505 event: event.event,
506 distinct_id: event.distinct_id,
507 properties: event.properties,
508 timestamp: event.timestamp.map(|timestamp| timestamp.and_utc()),
509 }
510 }
511}
512
513#[cfg(test)]
514pub mod tests {
515 use uuid::Uuid;
516
517 use crate::{event::InnerEvent, Error, Event};
518
519 fn build_v0(mut event: Event) -> InnerEvent {
521 event.prepare_for_v0();
522 InnerEvent::new(event, "test_api_key".to_string())
523 }
524
525 #[cfg(not(feature = "capture-v1"))]
526 fn build_v0_batch_event(mut event: Event) -> InnerEvent {
527 event.prepare_for_v0();
528 InnerEvent::new_for_batch(event)
529 }
530
531 #[test]
532 fn v0_adds_lib_properties() {
533 let mut event = Event::new("unit test event", "1234");
534 event.insert_prop("key1", "value1").unwrap();
535
536 let inner = build_v0(event);
537 assert_eq!(
538 inner.properties.get("$lib"),
539 Some(&serde_json::Value::String("posthog-rs".to_string()))
540 );
541 }
542
543 #[test]
544 fn v0_serializes_distinct_id_at_root() {
545 let inner = build_v0(Event::new("test", "user1"));
546 let json = serde_json::to_value(&inner).unwrap();
547
548 assert_eq!(json["distinct_id"], "user1");
551 assert!(json.get("$distinct_id").is_none());
552 }
553
554 #[cfg(not(feature = "capture-v1"))]
555 #[test]
556 fn v0_batch_serializes_distinct_id_at_root() {
557 use crate::event::BatchRequest;
558
559 let batch = BatchRequest {
560 api_key: "test_api_key".to_string(),
561 historical_migration: false,
562 sent_at: "2026-01-01T00:00:00Z".to_string(),
563 batch: vec![
564 build_v0_batch_event(Event::new("e1", "user1")),
565 build_v0_batch_event(Event::new("e2", "user2")),
566 ],
567 };
568 let json = serde_json::to_value(&batch).unwrap();
569
570 assert_eq!(json["api_key"], "test_api_key");
571
572 let events = json["batch"].as_array().expect("batch is an array");
573 for (event, expected_id) in events.iter().zip(["user1", "user2"]) {
574 assert_eq!(event["distinct_id"], expected_id);
575 assert!(event.get("$distinct_id").is_none());
576 assert!(event.get("api_key").is_none());
577 }
578 }
579
580 #[test]
581 fn v0_serializes_non_utc_timestamp_as_equivalent_utc_instant() {
582 let mut event = Event::new("test", "user1");
583 event
584 .set_timestamp(
585 chrono::DateTime::parse_from_rfc3339("2023-01-01T10:00:00.123+03:00").unwrap(),
586 )
587 .unwrap();
588
589 let json = serde_json::to_value(build_v0(event)).unwrap();
590 assert_eq!(json["timestamp"], "2023-01-01T07:00:00.123Z");
591 }
592
593 #[test]
594 fn v0_includes_auto_generated_uuid() {
595 let event = Event::new("test", "user1");
596 let inner = build_v0(event);
597 let json = serde_json::to_value(&inner).unwrap();
598
599 let uuid_str = json["uuid"].as_str().expect("uuid should be present");
600 Uuid::parse_str(uuid_str).expect("uuid should be valid");
601 }
602
603 #[test]
604 fn v0_preserves_overridden_uuid() {
605 let uuid = Uuid::now_v7();
606 let mut event = Event::new("test", "user1");
607 event.set_uuid(uuid);
608
609 let inner = build_v0(event);
610 let json = serde_json::to_value(&inner).unwrap();
611 assert_eq!(json["uuid"], uuid.to_string());
612 }
613
614 #[test]
615 fn v0_preserves_existing_lib_properties() {
616 let mut event = Event::new("forwarded event", "user1");
617 event.insert_prop("$lib", "posthog-js").unwrap();
618 event.insert_prop("$lib_version", "1.42.0").unwrap();
619 event.insert_prop("$lib_version__major", 1u64).unwrap();
620
621 let inner = build_v0(event);
622 let props = &inner.properties;
623
624 assert_eq!(
625 props.get("$lib"),
626 Some(&serde_json::Value::String("posthog-js".to_string()))
627 );
628 assert_eq!(
629 props.get("$lib_version"),
630 Some(&serde_json::Value::String("1.42.0".to_string()))
631 );
632 assert_eq!(
633 props.get("$lib_version__major"),
634 Some(&serde_json::Value::Number(1u64.into()))
635 );
636 }
637
638 #[test]
639 fn v0_injects_process_person_profile_for_anon() {
640 let event = Event::new_anon("anon_test");
641 let inner = build_v0(event);
642 assert_eq!(
643 inner.properties.get("$process_person_profile"),
644 Some(&serde_json::Value::Bool(false))
645 );
646 }
647
648 #[test]
649 fn v0_injects_process_person_profile_for_group() {
650 let mut event = Event::new("test", "user1");
651 event.add_group("company", "acme");
652 let inner = build_v0(event);
653 assert_eq!(
654 inner.properties.get("$process_person_profile"),
655 Some(&serde_json::Value::Bool(true))
656 );
657 }
658
659 #[test]
660 fn v0_no_process_person_profile_when_unset() {
661 let event = Event::new("test", "user1");
662 let inner = build_v0(event);
663 assert!(!inner.properties.contains_key("$process_person_profile"));
664 }
665
666 #[test]
667 fn v0_user_property_wins_over_constructor_default() {
668 let mut event = Event::new_anon("test");
669 event.insert_prop("$process_person_profile", true).unwrap();
671 let inner = build_v0(event);
672 assert_eq!(
673 inner.properties.get("$process_person_profile"),
674 Some(&serde_json::Value::Bool(true)),
675 );
676 }
677
678 #[test]
679 fn v0_identified_event_with_explicit_personless() {
680 let mut event = Event::new("test", "user1");
681 event.insert_prop("$process_person_profile", false).unwrap();
682 let inner = build_v0(event);
683 assert_eq!(
684 inner.properties.get("$process_person_profile"),
685 Some(&serde_json::Value::Bool(false)),
686 );
687 }
688
689 #[test]
690 fn v0_add_group_overrides_anon_person_profile() {
691 let mut event = Event::new_anon("test");
692 event.add_group("company", "acme");
694 let inner = build_v0(event);
695 assert_eq!(
696 inner.properties.get("$process_person_profile"),
697 Some(&serde_json::Value::Bool(true)),
698 );
699 let groups = inner
700 .properties
701 .get("$groups")
702 .unwrap()
703 .as_object()
704 .unwrap();
705 assert_eq!(groups.get("company").unwrap().as_str().unwrap(), "acme");
706 }
707
708 #[test]
709 fn v0_group_identify_payload() {
710 let event = Event::group_identify(
711 "company".to_string(),
712 "acme_123".to_string(),
713 serde_json::json!({ "name": "Acme Inc.", "employees": 42 }),
714 )
715 .expect("group_identify should succeed")
716 .expect("group_identify should not be dropped");
717
718 let inner = build_v0(event);
719 let json = serde_json::to_value(&inner).unwrap();
720
721 assert_eq!(json["event"], "$groupidentify");
722 assert_eq!(json["distinct_id"], "$company_acme_123");
723 assert_eq!(json["properties"]["$group_type"], "company");
724 assert_eq!(json["properties"]["$group_key"], "acme_123");
725 assert_eq!(json["properties"]["$group_set"]["name"], "Acme Inc.");
726 assert_eq!(json["properties"]["$group_set"]["employees"], 42);
727 assert!(!inner.properties.contains_key("$process_person_profile"));
728 }
729
730 #[test]
731 fn group_identify_rejects_blank_keys() {
732 assert!(
733 Event::group_identify("".to_string(), "k".to_string(), serde_json::json!({}))
734 .unwrap()
735 .is_none()
736 );
737 assert!(
738 Event::group_identify(" ".to_string(), "k".to_string(), serde_json::json!({}))
739 .unwrap()
740 .is_none()
741 );
742 assert!(
743 Event::group_identify("t".to_string(), "".to_string(), serde_json::json!({}))
744 .unwrap()
745 .is_none()
746 );
747 assert!(
748 Event::group_identify("t".to_string(), " ".to_string(), serde_json::json!({}))
749 .unwrap()
750 .is_none()
751 );
752 }
753
754 #[test]
755 fn group_identify_rejects_non_object_properties() {
756 let err = Event::group_identify("company".to_string(), "acme_123".to_string(), 42)
757 .expect_err("non-object properties should be rejected");
758 assert!(matches!(err, Error::Serialization(_)));
759
760 let err = Event::group_identify(
761 "company".to_string(),
762 "acme_123".to_string(),
763 serde_json::json!(["a", "b"]),
764 )
765 .expect_err("array properties should be rejected");
766 assert!(matches!(err, Error::Serialization(_)));
767
768 let err = Event::group_identify(
769 "company".to_string(),
770 "acme_123".to_string(),
771 serde_json::Value::Null,
772 )
773 .expect_err("null properties should be rejected");
774 assert!(matches!(err, Error::Serialization(_)));
775 }
776}
777
778#[cfg(test)]
779mod test {
780 use std::time::Duration;
781
782 use chrono::{DateTime, Utc};
783
784 use super::Event;
785
786 #[test]
787 fn test_timestamp_is_correctly_set() {
788 let mut event = Event::new_anon("test");
789 let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
790 event.set_timestamp(ts).expect("Date is not in the future");
791 let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
792 assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
793 }
794
795 #[test]
796 fn test_timestamp_is_correctly_set_with_future_date() {
797 let mut event = Event::new_anon("test");
798 let ts = Utc::now() + Duration::from_secs(60);
799 event
800 .set_timestamp(ts)
801 .expect_err("Date is in the future, should be rejected");
802
803 assert!(event.timestamp.is_none())
804 }
805
806 #[test]
807 fn ensure_timestamp_stamps_only_when_unset() {
808 let now = DateTime::parse_from_rfc3339("2026-06-17T12:00:00Z")
809 .unwrap()
810 .with_timezone(&Utc);
811
812 let mut event = Event::new("test", "user1");
814 event.ensure_timestamp(now);
815 assert_eq!(event.timestamp, Some(now.naive_utc()));
816
817 let mut event = Event::new("test", "user1");
819 let caller = DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
820 .unwrap()
821 .with_timezone(&Utc);
822 event.set_timestamp(caller).unwrap();
823 event.ensure_timestamp(now);
824 assert_eq!(event.timestamp, Some(caller.naive_utc()));
825 }
826}