dregs 1.0.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation
//! The events [`Client::track`](crate::Client::track) sends.

use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
use serde::Serialize;
use serde_json::{Map, Value};

use crate::error::{Error, Result};

/// The group type used when a [`Group`] is given none.
pub const DEFAULT_GROUP_TYPE: &str = "organization";

/// The longest event id Dregs accepts, in characters.
const MAX_EVENT_ID_LENGTH: usize = 64;

/// A backend event to record against an identity, passed to
/// [`Client::track`](crate::Client::track).
///
/// ```
/// use dregs::{Event, Group};
/// use serde_json::json;
///
/// let event = Event::new("user.signup", "user_12345")
///     .data(json!({ "plan": "pro", "referrer": "partner-x" }))
///     .identity_data(json!({ "email": "ada@example.com", "name": "Ada Lovelace" }))
///     .group(Group::organization("org_678").data(json!({ "name": "Acme Inc" })))
///     .event_id("signup-991");
/// ```
///
/// Everything but the type and the identity is optional. Problems with the event, such as an
/// empty identity or a data value that is not a JSON object, are reported by `track()` as
/// [`Error::InvalidArgument`] before any request is made.
#[derive(Debug, Clone)]
pub struct Event {
    event_type: String,
    identity: String,
    data: Option<Serialized>,
    identity_data: Option<Serialized>,
    groups: Vec<Group>,
    event_id: Option<String>,
    timestamp: Option<DateTime<Utc>>,
    source: Option<String>,
}

impl Event {
    /// Starts an event of `event_type` against `identity`.
    ///
    /// `event_type` is your name for the event, such as `"user.signup"`. Map it to one of Dregs's
    /// canonical types under **Settings → Mappings** so the analyzers know what it means.
    ///
    /// `identity` is your own id for the user. This is the same id you pass to
    /// `dregs.identify()` in the browser tracker, and the one you look scores up by. It is
    /// required: a server-side event carries no device signature, so the identity is the only
    /// thing tying it to a user.
    pub fn new(event_type: impl Into<String>, identity: impl Into<String>) -> Self {
        Event {
            event_type: event_type.into(),
            identity: identity.into(),
            data: None,
            identity_data: None,
            groups: Vec::new(),
            event_id: None,
            timestamp: None,
            source: None,
        }
    }

    /// Attributes of the event itself. Anything that serializes to a JSON object works: a
    /// `serde_json::json!({...})` value, a map, or your own `Serialize` struct.
    pub fn data(mut self, data: impl Serialize) -> Self {
        self.data = Some(Serialized::from(data));
        self
    }

    /// Attributes of the *user*, such as email, name, or username.
    ///
    /// Dregs merges these into the identity, and the analyzers lean on them heavily, so send them
    /// whenever you have them. Flat keys work best; name them as your application already does
    /// and map them under **Settings → Mappings**.
    pub fn identity_data(mut self, data: impl Serialize) -> Self {
        self.identity_data = Some(Serialized::from(data));
        self
    }

    /// Adds a group the user is acting in, such as their organization or team.
    ///
    /// Dregs records each group and makes the identity a member of it. An event can carry one
    /// group of each type.
    pub fn group(mut self, group: Group) -> Self {
        self.groups.push(group);
        self
    }

    /// Adds several groups at once. See [`Event::group`].
    pub fn groups(mut self, groups: impl IntoIterator<Item = Group>) -> Self {
        self.groups.extend(groups);
        self
    }

    /// Your own id for the event, which makes ingestion idempotent: reposting the same id returns
    /// the original event instead of recording a second one.
    ///
    /// Pass the id your application already has (the row id of the record that triggered the
    /// event, say). When you omit it the SDK generates one, which is what makes its own retries
    /// safe. At most 64 characters, and it cannot start with `dregs-`.
    pub fn event_id(mut self, event_id: impl Into<String>) -> Self {
        self.event_id = Some(event_id.into());
        self
    }

    /// When the event happened, if not now. Any time zone works; it is sent as UTC.
    pub fn timestamp<Tz: TimeZone>(mut self, timestamp: DateTime<Tz>) -> Self {
        self.timestamp = Some(timestamp.with_timezone(&Utc));
        self
    }

    /// A label for where the event came from. Defaults to `"rust-sdk"`.
    pub fn source(mut self, source: impl Into<String>) -> Self {
        self.source = Some(source.into());
        self
    }

    /// Builds the `POST /api/events` body.
    ///
    /// An event id is always sent. When the caller has an id of their own it is used verbatim,
    /// so reposting the same event is a no-op on the Dregs side; otherwise one is generated, which
    /// is what makes this client's own retries safe.
    pub(crate) fn into_body(self) -> Result<Value> {
        if self.event_type.is_empty() {
            return Err(invalid("The event type is required."));
        }

        if self.identity.is_empty() {
            return Err(invalid(
                "identity is required. A server-side event has no device signature, so the \
                 identity is the only thing tying the event to a user.",
            ));
        }

        let groups = groups_body(self.groups)?;
        let data = object_or_empty(self.data, "data")?;
        let identity_data = object_or_empty(self.identity_data, "identity_data")?;
        let event_id = self
            .event_id
            .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());

        if event_id.starts_with("dregs-") {
            return Err(invalid(
                "Event ids starting with 'dregs-' are reserved for Dregs itself.",
            ));
        }

        if event_id.chars().count() > MAX_EVENT_ID_LENGTH {
            return Err(invalid("Event ids cannot be longer than 64 characters."));
        }

        let mut body = Map::new();

        body.insert("id".into(), event_id.into());
        body.insert("type".into(), self.event_type.into());
        body.insert("data".into(), Value::Object(data));
        body.insert(
            "identity".into(),
            serde_json::json!({ "id": self.identity, "data": identity_data }),
        );
        body.insert(
            "source".into(),
            self.source.unwrap_or_else(|| "rust-sdk".into()).into(),
        );

        if !groups.is_empty() {
            body.insert("groups".into(), Value::Array(groups));
        }

        if let Some(timestamp) = self.timestamp {
            body.insert(
                "timestamp".into(),
                timestamp
                    .to_rfc3339_opts(SecondsFormat::AutoSi, true)
                    .into(),
            );
        }

        Ok(Value::Object(body))
    }
}

/// A group the user is acting in, such as their organization, team, or workspace.
///
/// ```
/// use dregs::Group;
/// use serde_json::json;
///
/// let organization = Group::organization("org_678").data(json!({ "name": "Acme Inc" }));
/// let team = Group::new("team", "team_42").data(json!({ "name": "Payments" }));
/// ```
///
/// The type is your own name for the kind of group. Dregs normalizes types to lower_snake_case,
/// so `ParentCompany` and `parent-company` are the same type. The data is merged into the group,
/// so later events can send the type and id alone.
///
/// The same builder creates a group directly, through
/// [`Groups::create`](crate::Groups::create).
#[derive(Debug, Clone)]
pub struct Group {
    group_type: String,
    id: String,
    data: Option<Serialized>,
}

impl Group {
    /// A group of `group_type` with your own `id` for it. An empty type means
    /// `"organization"`.
    pub fn new(group_type: impl Into<String>, id: impl Into<String>) -> Self {
        Group {
            group_type: group_type.into(),
            id: id.into(),
            data: None,
        }
    }

    /// An `"organization"` group, the most common kind.
    pub fn organization(id: impl Into<String>) -> Self {
        Group::new(DEFAULT_GROUP_TYPE, id)
    }

    /// Attributes of the group, merged into it on the Dregs side. Anything that serializes to a
    /// JSON object works.
    pub fn data(mut self, data: impl Serialize) -> Self {
        self.data = Some(Serialized::from(data));
        self
    }
}

impl Group {
    /// Builds the `POST /api/groups` body: the group's type (`"organization"` when empty), its
    /// id, and its data as the attributes to merge in.
    pub(crate) fn into_create_body(self) -> Result<Value> {
        if self.id.is_empty() {
            return Err(invalid("A group id is required."));
        }

        let group_type = if self.group_type.is_empty() {
            DEFAULT_GROUP_TYPE.to_owned()
        } else {
            self.group_type
        };

        let attributes = object_or_empty(self.data, "A group's data")?;

        Ok(serde_json::json!({ "type": group_type, "id": self.id, "attributes": attributes }))
    }
}

/// Builds the `groups` list of a track body, refusing malformed entries before any request.
fn groups_body(groups: Vec<Group>) -> Result<Vec<Value>> {
    let mut resolved = Vec::with_capacity(groups.len());
    let mut seen: Vec<String> = Vec::with_capacity(groups.len());

    for group in groups {
        if group.id.is_empty() {
            return Err(invalid("Each group needs an id."));
        }

        let group_type = if group.group_type.is_empty() {
            DEFAULT_GROUP_TYPE.to_owned()
        } else {
            group.group_type
        };

        let folded = group_type.to_lowercase();

        if seen.contains(&folded) {
            return Err(invalid(format!(
                "Only one group of each type can go with an event; '{group_type}' appears twice."
            )));
        }

        let data = object_or_empty(group.data, "A group's data")?;

        seen.push(folded);
        resolved.push(serde_json::json!({ "type": group_type, "id": group.id, "data": data }));
    }

    Ok(resolved)
}

/// A value already run through serde, or the reason it could not be.
///
/// Serializing eagerly keeps `Event` free of type parameters, and keeping the error rather than
/// panicking lets `track()` report it like any other problem with the event.
#[derive(Debug, Clone)]
pub(crate) struct Serialized(std::result::Result<Value, String>);

impl Serialized {
    pub(crate) fn from(data: impl Serialize) -> Self {
        Serialized(serde_json::to_value(data).map_err(|error| error.to_string()))
    }
}

pub(crate) fn object_or_empty(data: Option<Serialized>, name: &str) -> Result<Map<String, Value>> {
    match data.map(|serialized| serialized.0) {
        None | Some(Ok(Value::Null)) => Ok(Map::new()),
        Some(Ok(Value::Object(map))) => Ok(map),
        Some(Ok(_)) => Err(invalid(format!(
            "{name} must serialize to a JSON object, such as json!({{\"name\": \"Payments\"}})."
        ))),
        Some(Err(error)) => Err(invalid(format!("{name} could not be serialized: {error}"))),
    }
}

fn invalid(message: impl Into<String>) -> Error {
    Error::InvalidArgument(message.into())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_whole_second_timestamp_has_no_fraction() {
        let body = Event::new("user.signup", "user_12345")
            .timestamp(Utc.with_ymd_and_hms(2026, 9, 21, 14, 22, 9).unwrap())
            .into_body()
            .unwrap();

        assert_eq!(body["timestamp"], "2026-09-21T14:22:09Z");
    }

    #[test]
    fn a_generated_id_is_32_hex_characters() {
        let body = Event::new("user.signup", "user_12345").into_body().unwrap();
        let id = body["id"].as_str().unwrap();

        assert_eq!(id.len(), 32);
        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
    }
}