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
12pub(crate) const MINIMAL_FLAG_CALLED_EVENT_PROPERTIES: &[&str] = &[
24 "$feature_flag",
26 "$feature_flag_response",
27 "$feature_flag_has_experiment",
28 "$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 "$groups",
38 "$process_person_profile",
39 "$geoip_disable",
40 "$session_id",
42 "$window_id",
43 "$device_id",
44 "$lib",
45 "$lib_version",
46 "$is_server",
47 "$os",
49 "$os_version",
50];
51
52pub(crate) fn is_minimal_flag_called_property(key: &str) -> bool {
58 MINIMAL_FLAG_CALLED_EVENT_PROPERTIES.contains(&key)
59}
60
61#[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 #[serde(skip)]
77 minimal_flag_called: bool,
78}
79
80impl Event {
81 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 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 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 pub fn remove_prop(&mut self, key: &str) -> Option<serde_json::Value> {
154 self.properties.remove(key)
155 }
156
157 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 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 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 pub fn set_uuid(&mut self, uuid: Uuid) {
216 self.uuid = uuid;
217 }
218
219 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 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
239 pub fn event_name(&self) -> &str {
240 &self.event
241 }
242
243 #[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 #[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 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 pub(crate) fn mark_minimal_flag_called(&mut self) {
287 self.minimal_flag_called = true;
288 }
289
290 #[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 #[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 #[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#[cfg(not(feature = "capture-v1"))]
365#[derive(Serialize)]
366pub struct BatchRequest {
367 pub api_key: String,
368 pub historical_migration: bool,
369 pub sent_at: String,
371 pub batch: Vec<InnerEvent>,
372}
373
374#[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<DateTime<Utc>>,
385}
386
387impl InnerEvent {
388 #[cfg(test)]
392 pub fn new(event: Event, api_key: String) -> Self {
393 Self::from_event(event, Some(api_key))
394 }
395
396 #[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.map(|timestamp| timestamp.and_utc()),
412 }
413 }
414}
415
416#[cfg(test)]
417pub mod tests {
418 use uuid::Uuid;
419
420 use crate::{event::InnerEvent, Event};
421
422 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 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_serializes_non_utc_timestamp_as_equivalent_utc_instant() {
485 let mut event = Event::new("test", "user1");
486 event
487 .set_timestamp(
488 chrono::DateTime::parse_from_rfc3339("2023-01-01T10:00:00.123+03:00").unwrap(),
489 )
490 .unwrap();
491
492 let json = serde_json::to_value(build_v0(event)).unwrap();
493 assert_eq!(json["timestamp"], "2023-01-01T07:00:00.123Z");
494 }
495
496 #[test]
497 fn v0_includes_auto_generated_uuid() {
498 let event = Event::new("test", "user1");
499 let inner = build_v0(event);
500 let json = serde_json::to_value(&inner).unwrap();
501
502 let uuid_str = json["uuid"].as_str().expect("uuid should be present");
503 Uuid::parse_str(uuid_str).expect("uuid should be valid");
504 }
505
506 #[test]
507 fn v0_preserves_overridden_uuid() {
508 let uuid = Uuid::now_v7();
509 let mut event = Event::new("test", "user1");
510 event.set_uuid(uuid);
511
512 let inner = build_v0(event);
513 let json = serde_json::to_value(&inner).unwrap();
514 assert_eq!(json["uuid"], uuid.to_string());
515 }
516
517 #[test]
518 fn v0_preserves_existing_lib_properties() {
519 let mut event = Event::new("forwarded event", "user1");
520 event.insert_prop("$lib", "posthog-js").unwrap();
521 event.insert_prop("$lib_version", "1.42.0").unwrap();
522 event.insert_prop("$lib_version__major", 1u64).unwrap();
523
524 let inner = build_v0(event);
525 let props = &inner.properties;
526
527 assert_eq!(
528 props.get("$lib"),
529 Some(&serde_json::Value::String("posthog-js".to_string()))
530 );
531 assert_eq!(
532 props.get("$lib_version"),
533 Some(&serde_json::Value::String("1.42.0".to_string()))
534 );
535 assert_eq!(
536 props.get("$lib_version__major"),
537 Some(&serde_json::Value::Number(1u64.into()))
538 );
539 }
540
541 #[test]
542 fn v0_injects_process_person_profile_for_anon() {
543 let event = Event::new_anon("anon_test");
544 let inner = build_v0(event);
545 assert_eq!(
546 inner.properties.get("$process_person_profile"),
547 Some(&serde_json::Value::Bool(false))
548 );
549 }
550
551 #[test]
552 fn v0_injects_process_person_profile_for_group() {
553 let mut event = Event::new("test", "user1");
554 event.add_group("company", "acme");
555 let inner = build_v0(event);
556 assert_eq!(
557 inner.properties.get("$process_person_profile"),
558 Some(&serde_json::Value::Bool(true))
559 );
560 }
561
562 #[test]
563 fn v0_no_process_person_profile_when_unset() {
564 let event = Event::new("test", "user1");
565 let inner = build_v0(event);
566 assert!(!inner.properties.contains_key("$process_person_profile"));
567 }
568
569 #[test]
570 fn v0_user_property_wins_over_constructor_default() {
571 let mut event = Event::new_anon("test");
572 event.insert_prop("$process_person_profile", true).unwrap();
574 let inner = build_v0(event);
575 assert_eq!(
576 inner.properties.get("$process_person_profile"),
577 Some(&serde_json::Value::Bool(true)),
578 );
579 }
580
581 #[test]
582 fn v0_identified_event_with_explicit_personless() {
583 let mut event = Event::new("test", "user1");
584 event.insert_prop("$process_person_profile", false).unwrap();
585 let inner = build_v0(event);
586 assert_eq!(
587 inner.properties.get("$process_person_profile"),
588 Some(&serde_json::Value::Bool(false)),
589 );
590 }
591
592 #[test]
593 fn v0_add_group_overrides_anon_person_profile() {
594 let mut event = Event::new_anon("test");
595 event.add_group("company", "acme");
597 let inner = build_v0(event);
598 assert_eq!(
599 inner.properties.get("$process_person_profile"),
600 Some(&serde_json::Value::Bool(true)),
601 );
602 let groups = inner
603 .properties
604 .get("$groups")
605 .unwrap()
606 .as_object()
607 .unwrap();
608 assert_eq!(groups.get("company").unwrap().as_str().unwrap(), "acme");
609 }
610}
611
612#[cfg(test)]
613mod test {
614 use std::time::Duration;
615
616 use chrono::{DateTime, Utc};
617
618 use super::Event;
619
620 #[test]
621 fn test_timestamp_is_correctly_set() {
622 let mut event = Event::new_anon("test");
623 let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
624 event.set_timestamp(ts).expect("Date is not in the future");
625 let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
626 assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
627 }
628
629 #[test]
630 fn test_timestamp_is_correctly_set_with_future_date() {
631 let mut event = Event::new_anon("test");
632 let ts = Utc::now() + Duration::from_secs(60);
633 event
634 .set_timestamp(ts)
635 .expect_err("Date is in the future, should be rejected");
636
637 assert!(event.timestamp.is_none())
638 }
639
640 #[test]
641 fn ensure_timestamp_stamps_only_when_unset() {
642 let now = DateTime::parse_from_rfc3339("2026-06-17T12:00:00Z")
643 .unwrap()
644 .with_timezone(&Utc);
645
646 let mut event = Event::new("test", "user1");
648 event.ensure_timestamp(now);
649 assert_eq!(event.timestamp, Some(now.naive_utc()));
650
651 let mut event = Event::new("test", "user1");
653 let caller = DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
654 .unwrap()
655 .with_timezone(&Utc);
656 event.set_timestamp(caller).unwrap();
657 event.ensure_timestamp(now);
658 assert_eq!(event.timestamp, Some(caller.naive_utc()));
659 }
660}