1use std::collections::HashMap;
2
3use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
4use semver::Version;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::client::CRATE_VERSION;
9use crate::feature_flag_evaluations::FeatureFlagEvaluations;
10use crate::Error;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
16pub struct EventOptions {
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub cookieless_mode: Option<bool>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub disable_skew_correction: Option<bool>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub product_tour_id: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub process_person_profile: Option<bool>,
25
26 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
27 pub extra: HashMap<String, serde_json::Value>,
28}
29
30#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
35pub struct Event {
36 event: String,
37 distinct_id: String,
38 properties: HashMap<String, serde_json::Value>,
39 groups: HashMap<String, String>,
40 timestamp: Option<NaiveDateTime>,
41 uuid: Uuid,
42 #[serde(skip)]
43 options: EventOptions,
44}
45
46impl Event {
47 pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
57 Self {
58 event: event.into(),
59 distinct_id: distinct_id.into(),
60 properties: HashMap::new(),
61 groups: HashMap::new(),
62 timestamp: None,
63 uuid: Uuid::now_v7(),
64 options: EventOptions::default(),
65 }
66 }
67
68 pub fn new_anon<S: Into<String>>(event: S) -> Self {
81 Self {
82 event: event.into(),
83 distinct_id: Uuid::now_v7().to_string(),
84 properties: HashMap::new(),
85 groups: HashMap::new(),
86 timestamp: None,
87 uuid: Uuid::now_v7(),
88 options: EventOptions {
89 process_person_profile: Some(false),
90 ..EventOptions::default()
91 },
92 }
93 }
94
95 pub fn insert_prop<K: Into<String>, P: Serialize>(
106 &mut self,
107 key: K,
108 prop: P,
109 ) -> Result<(), Error> {
110 let as_json =
111 serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?;
112 let _ = self.properties.insert(key.into(), as_json);
113 Ok(())
114 }
115
116 pub fn remove_prop(&mut self, key: &str) -> Option<serde_json::Value> {
118 self.properties.remove(key)
119 }
120
121 pub fn add_group(&mut self, group_name: &str, group_id: &str) {
136 self.options.process_person_profile = Some(true);
137 self.groups.insert(group_name.into(), group_id.into());
138 }
139
140 pub fn set_timestamp<Tz>(&mut self, timestamp: DateTime<Tz>) -> Result<(), Error>
151 where
152 Tz: TimeZone,
153 {
154 if timestamp > Utc::now() + Duration::seconds(1) {
155 return Err(Error::InvalidTimestamp(String::from(
156 "Events cannot occur in the future",
157 )));
158 }
159 self.timestamp = Some(timestamp.naive_utc());
160 Ok(())
161 }
162
163 pub fn set_uuid(&mut self, uuid: Uuid) {
167 self.uuid = uuid;
168 }
169
170 pub fn with_flags(&mut self, flags: &FeatureFlagEvaluations) -> &mut Self {
182 for (key, value) in flags.event_properties() {
183 self.properties.insert(key, value);
184 }
185 self
186 }
187
188 #[cfg(feature = "capture-v1")]
191 pub fn set_option<V: Serialize>(&mut self, key: &str, value: V) -> Result<(), Error> {
192 let val = serde_json::to_value(value).map_err(|e| Error::Serialization(e.to_string()))?;
193 match key {
194 "cookieless_mode" => self.options.cookieless_mode = val.as_bool(),
195 "disable_skew_correction" => self.options.disable_skew_correction = val.as_bool(),
196 "process_person_profile" => self.options.process_person_profile = val.as_bool(),
197 "product_tour_id" => self.options.product_tour_id = val.as_str().map(|s| s.to_string()),
198 _ => {
199 self.options.extra.insert(key.to_owned(), val);
200 }
201 }
202 Ok(())
203 }
204
205 #[cfg(feature = "capture-v1")]
207 pub fn options(&self) -> &EventOptions {
208 &self.options
209 }
210
211 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
213 pub fn event_name(&self) -> &str {
214 &self.event
215 }
216
217 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
219 pub fn distinct_id(&self) -> &str {
220 &self.distinct_id
221 }
222
223 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
224 pub(crate) fn uuid(&self) -> Uuid {
225 self.uuid
226 }
227
228 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
229 pub(crate) fn timestamp(&self) -> Option<NaiveDateTime> {
230 self.timestamp
231 }
232
233 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
235 pub fn properties(&self) -> &HashMap<String, serde_json::Value> {
236 &self.properties
237 }
238
239 pub(crate) fn insert_prop_default<K: Into<String>>(
245 &mut self,
246 key: K,
247 value: serde_json::Value,
248 ) {
249 self.properties.entry(key.into()).or_insert(value);
250 }
251
252 #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
253 pub(crate) fn groups(&self) -> &HashMap<String, String> {
254 &self.groups
255 }
256
257 #[cfg_attr(feature = "capture-v1", allow(dead_code))]
261 pub(crate) fn prepare_for_v0(&mut self) {
262 if !self.properties.contains_key("$lib") {
263 self.properties.insert(
264 "$lib".into(),
265 serde_json::Value::String("posthog-rs".into()),
266 );
267 }
268
269 let version_str = CRATE_VERSION;
270 if !self.properties.contains_key("$lib_version") {
271 self.properties.insert(
272 "$lib_version".into(),
273 serde_json::Value::String(version_str.into()),
274 );
275 }
276
277 if !self.properties.contains_key("$lib_version__major") {
278 if let Ok(version) = version_str.parse::<Version>() {
279 self.properties.insert(
280 "$lib_version__major".into(),
281 serde_json::Value::Number(version.major.into()),
282 );
283 self.properties.insert(
284 "$lib_version__minor".into(),
285 serde_json::Value::Number(version.minor.into()),
286 );
287 self.properties.insert(
288 "$lib_version__patch".into(),
289 serde_json::Value::Number(version.patch.into()),
290 );
291 }
292 }
293
294 if let Some(ppp) = self.options.process_person_profile {
295 self.properties
296 .entry("$process_person_profile".into())
297 .or_insert_with(|| serde_json::Value::Bool(ppp));
298 }
299
300 if !self.groups.is_empty() {
301 self.properties.insert(
302 "$groups".into(),
303 serde_json::Value::Object(
304 self.groups
305 .iter()
306 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
307 .collect(),
308 ),
309 );
310 }
311 }
312}
313
314#[cfg(not(feature = "capture-v1"))]
317#[derive(Serialize)]
318pub struct BatchRequest {
319 pub api_key: String,
320 pub historical_migration: bool,
321 pub batch: Vec<InnerEvent>,
322}
323
324#[cfg_attr(feature = "capture-v1", allow(dead_code))]
326#[derive(Serialize)]
327pub struct InnerEvent {
328 #[serde(skip_serializing_if = "Option::is_none")]
329 api_key: Option<String>,
330 uuid: Uuid,
331 event: String,
332 distinct_id: String,
333 properties: HashMap<String, serde_json::Value>,
334 timestamp: Option<NaiveDateTime>,
335}
336
337impl InnerEvent {
338 #[cfg_attr(feature = "capture-v1", allow(dead_code))]
342 pub fn new(event: Event, api_key: String) -> Self {
343 Self::from_event(event, Some(api_key))
344 }
345
346 #[cfg(not(feature = "capture-v1"))]
349 pub(crate) fn new_for_batch(event: Event) -> Self {
350 Self::from_event(event, None)
351 }
352
353 fn from_event(event: Event, api_key: Option<String>) -> Self {
354 Self {
355 api_key,
356 uuid: event.uuid,
357 event: event.event,
358 distinct_id: event.distinct_id,
359 properties: event.properties,
360 timestamp: event.timestamp,
361 }
362 }
363}
364
365#[cfg(test)]
366pub mod tests {
367 use uuid::Uuid;
368
369 use crate::{event::InnerEvent, Event};
370
371 fn build_v0(mut event: Event) -> InnerEvent {
373 event.prepare_for_v0();
374 InnerEvent::new(event, "test_api_key".to_string())
375 }
376
377 #[cfg(not(feature = "capture-v1"))]
378 fn build_v0_batch_event(mut event: Event) -> InnerEvent {
379 event.prepare_for_v0();
380 InnerEvent::new_for_batch(event)
381 }
382
383 #[test]
384 fn v0_adds_lib_properties() {
385 let mut event = Event::new("unit test event", "1234");
386 event.insert_prop("key1", "value1").unwrap();
387
388 let inner = build_v0(event);
389 assert_eq!(
390 inner.properties.get("$lib"),
391 Some(&serde_json::Value::String("posthog-rs".to_string()))
392 );
393 }
394
395 #[test]
396 fn v0_serializes_distinct_id_at_root() {
397 let inner = build_v0(Event::new("test", "user1"));
398 let json = serde_json::to_value(&inner).unwrap();
399
400 assert_eq!(json["distinct_id"], "user1");
403 assert!(json.get("$distinct_id").is_none());
404 }
405
406 #[cfg(not(feature = "capture-v1"))]
407 #[test]
408 fn v0_batch_serializes_distinct_id_at_root() {
409 use crate::event::BatchRequest;
410
411 let batch = BatchRequest {
412 api_key: "test_api_key".to_string(),
413 historical_migration: false,
414 batch: vec![
415 build_v0_batch_event(Event::new("e1", "user1")),
416 build_v0_batch_event(Event::new("e2", "user2")),
417 ],
418 };
419 let json = serde_json::to_value(&batch).unwrap();
420
421 assert_eq!(json["api_key"], "test_api_key");
422
423 let events = json["batch"].as_array().expect("batch is an array");
424 for (event, expected_id) in events.iter().zip(["user1", "user2"]) {
425 assert_eq!(event["distinct_id"], expected_id);
426 assert!(event.get("$distinct_id").is_none());
427 assert!(event.get("api_key").is_none());
428 }
429 }
430
431 #[test]
432 fn v0_includes_auto_generated_uuid() {
433 let event = Event::new("test", "user1");
434 let inner = build_v0(event);
435 let json = serde_json::to_value(&inner).unwrap();
436
437 let uuid_str = json["uuid"].as_str().expect("uuid should be present");
438 Uuid::parse_str(uuid_str).expect("uuid should be valid");
439 }
440
441 #[test]
442 fn v0_preserves_overridden_uuid() {
443 let uuid = Uuid::now_v7();
444 let mut event = Event::new("test", "user1");
445 event.set_uuid(uuid);
446
447 let inner = build_v0(event);
448 let json = serde_json::to_value(&inner).unwrap();
449 assert_eq!(json["uuid"], uuid.to_string());
450 }
451
452 #[test]
453 fn v0_preserves_existing_lib_properties() {
454 let mut event = Event::new("forwarded event", "user1");
455 event.insert_prop("$lib", "posthog-js").unwrap();
456 event.insert_prop("$lib_version", "1.42.0").unwrap();
457 event.insert_prop("$lib_version__major", 1u64).unwrap();
458
459 let inner = build_v0(event);
460 let props = &inner.properties;
461
462 assert_eq!(
463 props.get("$lib"),
464 Some(&serde_json::Value::String("posthog-js".to_string()))
465 );
466 assert_eq!(
467 props.get("$lib_version"),
468 Some(&serde_json::Value::String("1.42.0".to_string()))
469 );
470 assert_eq!(
471 props.get("$lib_version__major"),
472 Some(&serde_json::Value::Number(1u64.into()))
473 );
474 }
475
476 #[test]
477 fn v0_injects_process_person_profile_for_anon() {
478 let event = Event::new_anon("anon_test");
479 let inner = build_v0(event);
480 assert_eq!(
481 inner.properties.get("$process_person_profile"),
482 Some(&serde_json::Value::Bool(false))
483 );
484 }
485
486 #[test]
487 fn v0_injects_process_person_profile_for_group() {
488 let mut event = Event::new("test", "user1");
489 event.add_group("company", "acme");
490 let inner = build_v0(event);
491 assert_eq!(
492 inner.properties.get("$process_person_profile"),
493 Some(&serde_json::Value::Bool(true))
494 );
495 }
496
497 #[test]
498 fn v0_no_process_person_profile_when_unset() {
499 let event = Event::new("test", "user1");
500 let inner = build_v0(event);
501 assert!(!inner.properties.contains_key("$process_person_profile"));
502 }
503
504 #[test]
505 fn v0_user_property_wins_over_options_injection() {
506 let mut event = Event::new_anon("test");
507 event.insert_prop("$process_person_profile", true).unwrap();
508 let inner = build_v0(event);
509 assert_eq!(
510 inner.properties.get("$process_person_profile"),
511 Some(&serde_json::Value::Bool(true)),
512 );
513 }
514}
515
516#[cfg(test)]
517mod test {
518 use std::time::Duration;
519
520 use chrono::{DateTime, Utc};
521
522 use super::Event;
523
524 #[test]
525 fn test_timestamp_is_correctly_set() {
526 let mut event = Event::new_anon("test");
527 let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
528 event.set_timestamp(ts).expect("Date is not in the future");
529 let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
530 assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
531 }
532
533 #[test]
534 fn test_timestamp_is_correctly_set_with_future_date() {
535 let mut event = Event::new_anon("test");
536 let ts = Utc::now() + Duration::from_secs(60);
537 event
538 .set_timestamp(ts)
539 .expect_err("Date is in the future, should be rejected");
540
541 assert!(event.timestamp.is_none())
542 }
543}