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#[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 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 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 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 pub fn remove_prop(&mut self, key: &str) -> Option<serde_json::Value> {
98 self.properties.remove(key)
99 }
100
101 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 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 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 pub fn set_uuid(&mut self, uuid: Uuid) {
160 self.uuid = uuid;
161 }
162
163 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 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
183 pub fn event_name(&self) -> &str {
184 &self.event
185 }
186
187 #[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 #[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 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 #[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#[cfg(not(feature = "capture-v1"))]
284#[derive(Serialize)]
285pub struct BatchRequest {
286 pub api_key: String,
287 pub historical_migration: bool,
288 pub sent_at: String,
290 pub batch: Vec<InnerEvent>,
291}
292
293#[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 #[cfg(test)]
311 pub fn new(event: Event, api_key: String) -> Self {
312 Self::from_event(event, Some(api_key))
313 }
314
315 #[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 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 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 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 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 let mut event = Event::new("test", "user1");
554 event.ensure_timestamp(now);
555 assert_eq!(event.timestamp, Some(now.naive_utc()));
556
557 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}