dregs 1.0.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation

Dregs Rust SDK

crates.io docs.rs License

The official Rust client for Dregs, which scores the users of your application for fraud and abuse across four categories: humanity, authenticity, uniqueness, and behavior.

Send events from your backend, read back the scores and the observations behind them.

cargo add dregs

The client is async and runs on Tokio. Rust 1.85 or newer.

Getting started

You need the secret key from an API credential, which you will find under Settings → Credentials in the Dregs dashboard. It starts with sk_. The pk_ public key is for the browser tracker and cannot read identities or scores.

use dregs::Client;

let client = Client::new(std::env::var("DREGS_SECRET_KEY")?)?;

Client::from_env() reads the key from DREGS_SECRET_KEY for you, which is usually all you need. The client holds a connection pool: build one at startup and keep it, rather than making a new one per request. Cloning is cheap and every clone shares the pool, so put it in your application state and hand clones to handlers and tasks freely.

Tracking events

use dregs::Event;
use serde_json::json;

client
    .track(
        Event::new("user.signup", "user_12345")
            .data(json!({ "plan": "pro", "referrer": "partner-x" }))
            .identity_data(json!({ "email": "ada@example.com", "name": "Ada Lovelace" })),
    )
    .await?;

The second argument to Event::new is your own id for the user — the same one 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 the event to a user.

identity_data carries attributes of the user rather than the event. The analyzers lean on these heavily, so send them whenever you have them. Name the keys the way your application already does and map them to Dregs's canonical fields under Settings → Mappings; the same goes for event names.

data and identity_data take anything that serializes to a JSON object: a json! value, a map, or your own #[derive(Serialize)] struct.

Groups

If your application groups users into organizations, teams, workspaces, or the like, pass the groups the user is acting in. Dregs records each group and makes the identity a member of it. Each group has your own id, a type that is your own name for the kind of group ("organization" when empty, and what Group::organization gives you), and optional data that Dregs merges into the group, so later events can send the type and id alone. Dregs normalizes types to lower_snake_case, so ParentCompany and parent-company are the same type, and an event can carry one group of each type.

use dregs::{Event, Group};

client
    .track(
        Event::new("user.login", "user_12345")
            .group(Group::organization("org_678").data(json!({ "name": "Acme Inc", "plan": "enterprise" })))
            .group(Group::new("team", "team_42").data(json!({ "name": "Payments" }))),
    )
    .await?;

Idempotency

Every event is sent with an id, 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, and a retry after a timeout can never double-count.

client
    .track(Event::new("purchase", "user_12345").event_id(format!("order-{}", order.id)))
    .await?;

When you omit it the SDK generates one, which is what makes its own retries safe.

What comes back

let result = client.track(Event::new("user.signup", "user_12345")).await?;

result.accepted(); // true when Dregs recorded the event
result.id; // the event's id

accepted() is false in the uncommon case where Dregs accepts the request without recording an event. Failures that are yours to act on come back as errors instead — see Errors.

Reading scores

let scores = client.identities().scores("user_12345").await?;

scores.humanity(); // Some(85)
scores.authenticity(); // Some(72)
scores.uniqueness(); // Some(91)
scores.behavior(); // Some(68)

This is the cheap read and the one most integrations want. A category Dregs has not scored yet reads as None, and a brand-new identity comes back empty. Scores also dereferences to a slice of Score, so you can iterate it.

Scoring is asynchronous. Scores appear moments after the events that move them, not in the same breath, so read them at a decision point rather than immediately after a track() call.

if scores.authenticity().is_some_and(|authenticity| authenticity < 40) {
    hold_for_review("user_12345").await;
}

Seeing exactly why

The scores are the summary; the observations are the evidence. When you need to show or log why an identity scored the way it did, ask for the analysis.

let analysis = client.identities().analysis("user_12345").await?;

for observation in analysis.observations() {
    println!(
        "{}: {} (value {:?})",
        observation.label.as_deref().unwrap_or_default(),
        observation.explanation.as_deref().unwrap_or_default(),
        observation.value,
    );
}

Each observation carries the analyzer that produced it, a value from 0.0 (suspicious) to 1.0 (legitimate), a confidence, a weight, and the counts behind the finding in metadata. analysis() returns a NotFound error until the identity has been analyzed at least once.

The whole identity

let identity = client.identities().get("user_12345").await?;

identity.display_email; // Some("ada@example.com")
identity.humanity_score; // Some(85)
identity.badges; // [Badge { name: Some("Account Takeover Suspected"), .. }]
identity.data; // every attribute you have sent

Forcing a rescore

client.identities().analyze("user_12345").await?;

This queues the work and returns; it does not wait for the cycle to finish. Dregs rescores on its own as events arrive, so you rarely need this outside of a support or backfill flow.

History, links, and past cycles

let history = client.identities().history("user_12345", Some(10)).await?;

for entry in &history {
    println!("{:?} {:?} {:?}", entry.finished_at, entry.humanity, entry.behavior);
}

// A past cycle's observations.
if let Some(cycle_id) = history.last().and_then(|entry| entry.cycle_id) {
    let older = client.identities().analysis_cycle("user_12345", cycle_id).await?;
}

for link in client.identities().links("user_12345").await? {
    println!("{:?} {:?} {:?}", link.identity_id, link.link_type, link.explanation); // e.g. "user_999", "SAME_DEVICE"
}

Disregarding an identity

Leave your own staff and test accounts out of scoring and escalations:

use dregs::IdentityUpdate;

client
    .identities()
    .update("qa_bot_7", IdentityUpdate::new().disregarded(true))
    .await?;

IdentityUpdate::attributes merges attributes into the identity the way identity_data on an event does. An update that sets nothing is refused before any request.

Asking for a review

When a score looks wrong, Dregs's AI reviewer will look over the analysis and say why. The review runs in the background, so read it back later with reviews().

let review = client
    .identities()
    .review("user_12345", Some("This is a long-standing customer."))
    .await?;

review.status; // Some("PENDING"), then Some("FINISHED")

Listing and paging

Every namespace with a list() returns one Page at a time, numbered from 0, and a list_all() that walks every page for you as a Stream. Each list() takes a builder for its filters and paging:

use dregs::{ListIdentities, Order};
use futures_util::TryStreamExt; // or futures::TryStreamExt

let page = client
    .identities()
    .list(ListIdentities::new().term("example.com").page_size(50))
    .await?;

page.total_elements; // how many match in all
page.has_next(); // whether another page follows

let mut identities = client.identities().list_all(
    ListIdentities::new().group("org_678").sort("humanityScore").order(Order::Asc),
);

while let Some(identity) = identities.try_next().await? {
    // ...
}

Filters you leave out are not sent. A page holds at most 100 items. Page dereferences to a slice of its items, and the stream owns a clone of the client, so it can be moved into a spawned task. A page that fails to load ends the stream with that error.

Groups

track() records groups and memberships as events arrive. The groups() namespace reads them back, and keeps Dregs in step when something changes outside an event, such as a user leaving a team:

use dregs::{Group, ListGroups};

client
    .groups()
    .create(Group::organization("org_678").data(json!({ "name": "Acme Inc", "plan": "enterprise" })))
    .await?;

let group = client.groups().get("organization", "org_678").await?;
group.identity_count; // Some(12)

client
    .groups()
    .update("organization", "org_678", json!({ "plan": "business" }))
    .await?;

client.groups().add_member("team", "team_42", "user_12345").await?;
client.groups().remove_member("team", "team_42", "user_12345").await?;

let mut teams = client
    .groups()
    .list_all(ListGroups::new().group_type("team").identity("user_12345"));

while let Some(team) = teams.try_next().await? {
    println!("{:?} {:?}", team.id, team.display_name);
}

create() takes the same Group builder as track(), and merges into the group when one with the same type and id already exists, so it is safe to call on every sync. The type defaults to "organization". The groups Dregs returns are TrackedGroups, to keep them apart from the builder.

Events, devices, and escalations

use dregs::{EscalationSeverity, EscalationStatus, ListEscalations, ListEvents};

let mut events = client.events().list_all(ListEvents::new().identity("user_12345"));

while let Some(event) = events.try_next().await? {
    println!("{:?} {:?} {:?}", event.timestamp, event.name, event.canonical_type);
}

client.devices().update("a1b2c3d4e5", true).await?; // disregard, e.g. a shared office machine

let mut escalations = client.escalations().list_all(
    ListEscalations::new()
        .status(EscalationStatus::Open)
        .severity(EscalationSeverity::Critical),
);

while let Some(escalation) = escalations.try_next().await? {
    notify_on_call(&escalation).await;

    if let Some(id) = escalation.id {
        client.escalations().update(id, EscalationStatus::Acknowledged).await?;
    }
}

let summary = client.escalations().summary().await?; // open, acknowledged, and closed counts

The events Dregs returns are TrackedEvents, to keep them apart from the Event builder track() takes.

Errors

Every call returns dregs::Result<T>, whose error is dregs::Error. When Dregs answered with an error, error.kind() says which:

use dregs::ApiErrorKind;

match client.track(Event::new("user.signup", "user_12345")).await {
    Ok(result) => { /* check result.accepted() */ }
    Err(error) => match error.kind() {
        Some(ApiErrorKind::QuotaExceeded) => { /* over the monthly event limit; the event was not queued */ }
        Some(ApiErrorKind::RateLimited) => {
            // Ingesting too fast. When the server said how long to wait:
            let wait = error.api_error().and_then(|api| api.retry_after);
        }
        _ => { /* anything else */ }
    },
}
Error When
ApiErrorKind::BadRequest 400, the event was malformed
ApiErrorKind::Authentication 401, the secret key was not recognized
ApiErrorKind::QuotaExceeded 402, the account is over its monthly event limit
ApiErrorKind::PermissionDenied 403, the credential may not do this
ApiErrorKind::NotFound 404, no such identity, group, event, device, or escalation, or no analysis yet
ApiErrorKind::RateLimited 429, too many requests
ApiErrorKind::Server 5xx
Error::Timeout the request timed out
Error::Connection the request never reached Dregs
Error::InvalidArgument refused before sending, such as an empty identity or an update that sets nothing

The API errors are Error::Api, carrying an ApiError with the status, message, parsed body, and request_id. When the error body carries no message, the message is the HTTP reason phrase, such as Not Found.

Retries

Connection failures, timeouts, 429s, and 5xx are retried automatically with exponential backoff and jitter, honouring Retry-After when the server sends one. Two retries by default:

let client = Client::builder().max_retries(5).build()?; // or 0 to handle it yourself

Concurrency

Every call is an async fn that resolves when Dregs has answered, so awaiting track() in a request handler makes that request wait on the round trip. Where the response does not depend on the outcome, spawn it instead:

let client = client.clone();

tokio::spawn(async move {
    if let Err(error) = client.track(Event::new("user.signup", user_id)).await {
        tracing::warn!(%error, "Dregs did not record the signup");
    }
});

Retries happen inside the spawned task, and the event id is generated before the first attempt, so a retry can never double-count.

Webhooks

Dregs signs every webhook with the channel's signing secret. Verify it against the raw request body before acting on the payload — a re-serialized value will not match, because key order and whitespace change.

use axum::{body::Bytes, http::{HeaderMap, StatusCode}};

async fn receive(headers: HeaderMap, body: Bytes) -> StatusCode {
    let signature = headers
        .get(dregs::webhooks::SIGNATURE_HEADER)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default();

    match dregs::webhooks::verify(&body, signature, &webhook_secret()) {
        Ok(event) => {
            handle(event);
            StatusCode::NO_CONTENT
        }
        Err(_) => StatusCode::BAD_REQUEST,
    }
}

verify also rejects payloads older than five minutes as replays; use verify_with and VerifyOptions::default().tolerance(None) to skip that if you are deduplicating on the event id yourself. The signing secret is shown once, when you create the webhook channel, and is not your API secret key.

Configuration

use std::time::Duration;

let client = Client::builder()
    .secret_key(key) // defaults to $DREGS_SECRET_KEY
    .base_url("https://dregs.com/api") // defaults to $DREGS_BASE_URL, then https://dregs.com/api
    .timeout(Duration::from_secs(10))
    .max_retries(2)
    .http_client(reqwest::Client::new()) // bring your own for proxies or custom TLS
    .build()?;

TLS

TLS goes through rustls by default, verifying certificates with the platform's verifier. To use the operating system's TLS library instead:

dregs = { version = "1", default-features = false, features = ["native-tls"] }

Types

Responses are plain structs with public fields. Each one also keeps the body it was built from in raw, so a field Dregs adds after this release is reachable without waiting for an SDK upgrade. Parsing is lenient: a field that is missing or not the expected type reads as None rather than failing the call. The response structs are #[non_exhaustive], so new fields arrive in minor releases.

Contributing

See CONTRIBUTING.md. The short version:

cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check

Links

License

MIT. See LICENSE.