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<NaiveDateTime>,
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,
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_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 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 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 let mut event = Event::new("test", "user1");
635 event.ensure_timestamp(now);
636 assert_eq!(event.timestamp, Some(now.naive_utc()));
637
638 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}