billdogeng 1.0.0-beta.1

Official BilldogEng server SDK for Rust — Analytics, Feature Flags (remote + local eval), Surveys, Messaging, and LLM observability.
Documentation
//! Shared public types for the BilldogEng Rust SDK.

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::HashMap;

/// Arbitrary JSON-serializable property bag.
pub type Properties = Map<String, Value>;

/// Map of group type → group key (e.g. `{ company: "acme", team: "core" }`).
pub type Groups = HashMap<String, String>;

/// Configuration for [`crate::BilldogEng`]. All fields have defaults via
/// [`BilldogEngOptions::default`].
#[derive(Debug, Clone)]
pub struct BilldogEngOptions {
    /// Base URL for all API requests. Default `https://api.billdog.io/v1`.
    pub host: String,
    /// Batch size that triggers an automatic flush. Default `20`.
    pub flush_at: usize,
    /// Background flush cadence in milliseconds. Default `10000`.
    pub flush_interval_ms: u64,
    /// Maximum number of queued events before the oldest are dropped. Default `1000`.
    pub max_queue_size: usize,
    /// Gzip request bodies when they are large enough to benefit. Default `true`.
    pub gzip: bool,
    /// Enable local (server-side) feature-flag evaluation. Default `false`.
    pub local_evaluation: bool,
    /// Per-request timeout in milliseconds. Default `10000`.
    pub request_timeout_ms: u64,
    /// Number of retry attempts for 5xx / network errors. Default `3`.
    pub max_retries: u32,
    /// Emit verbose diagnostics to stderr. Default `false`.
    pub enable_logging: bool,
    /// Stable group-type → positional-index (`0..=4`) mapping. When provided, the
    /// SDK mirrors `groups[type]` into `properties.$group_<index>`.
    pub group_type_index: Option<HashMap<String, u8>>,
}

impl Default for BilldogEngOptions {
    fn default() -> Self {
        Self {
            host: "https://api.billdog.io/v1".to_string(),
            flush_at: 20,
            flush_interval_ms: 10_000,
            max_queue_size: 1000,
            gzip: true,
            local_evaluation: false,
            request_timeout_ms: 10_000,
            max_retries: 3,
            enable_logging: false,
            group_type_index: None,
        }
    }
}

/// The on-wire shape of a single analytics event (one element of the batch).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireEvent {
    pub event_name: String,
    /// Epoch milliseconds.
    pub event_timestamp: u64,
    pub properties: Properties,
    /// Maps from `distinct_id`.
    pub user_id: String,
}

/// Options accepted by the feature-flag read methods.
#[derive(Debug, Clone, Default)]
pub struct FlagEvalOptions {
    /// Group memberships (reserved for future group targeting).
    pub groups: Option<Groups>,
    /// Person attributes evaluated against a flag's `targeting_rules`.
    pub person_properties: Option<Properties>,
}

/// The evaluated value of a feature flag.
#[derive(Debug, Clone, PartialEq)]
pub enum FlagValue {
    /// Simple on/off flag.
    Bool(bool),
    /// Multivariate flag — the chosen variant key.
    Variant(String),
}

impl FlagValue {
    /// Whether this value counts as "enabled" (`true` or any variant string).
    pub fn is_enabled(&self) -> bool {
        match self {
            FlagValue::Bool(b) => *b,
            FlagValue::Variant(_) => true,
        }
    }

    /// Serialize to a JSON value (`bool` or string).
    pub fn to_json(&self) -> Value {
        match self {
            FlagValue::Bool(b) => Value::Bool(*b),
            FlagValue::Variant(s) => Value::String(s.clone()),
        }
    }
}

/// A single multivariate flag variant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlagVariant {
    pub key: String,
    pub rollout_percentage: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payload: Option<Value>,
}

/// A targeting rule operator.
///
/// Mirrors the backend audience engine's `compare()` operator set in
/// `supabase/functions/_shared/edge-serving/pure.ts`, including aliases. The
/// legacy `gt`/`lt` aliases are kept for back-compat with existing flag defs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Operator {
    /// Actual present AND non-empty string.
    Exists,
    /// Actual absent/empty.
    NotExists,
    Is,
    Equals,
    IsNot,
    NotEquals,
    AnyOf,
    NotAnyOf,
    Contains,
    NotContains,
    GreaterThan,
    /// Alias for `greater_than` (legacy back-compat).
    Gt,
    LessThan,
    /// Alias for `less_than` (legacy back-compat).
    Lt,
    GreaterThanOrEqual,
    Gte,
    LessThanOrEqual,
    Lte,
}

/// A single targeting rule. ALL rules must match for a flag to be ON.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetingRule {
    pub attribute: String,
    pub operator: Operator,
    pub value: Value,
}

/// A feature-flag definition returned by `POST /feature-flag-definitions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureFlagDefinition {
    pub key: String,
    pub active: bool,
    pub rollout_percentage: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub variants: Option<Vec<FlagVariant>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub targeting_rules: Option<Vec<TargetingRule>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payload: Option<Value>,
}

// ─── Surveys ──────────────────────────────────────────────────────────────

/// A survey summary returned by `POST /bdsurvey-list`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SurveyListItem {
    pub id: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
    pub survey_type: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
}

/// A single survey answer. Mirrors the bdsurvey-submit answer shape.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SurveyAnswer {
    pub question_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub choice_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub answer_text: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub answer_number: Option<f64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub answer_json: Option<Value>,
}

/// Optional context passed to survey lifecycle calls.
#[derive(Debug, Clone, Default)]
pub struct SurveyContext {
    pub respondent_id: Option<String>,
    pub customer_id: Option<String>,
    pub anonymous_id: Option<String>,
    pub session_id: Option<String>,
    pub platform: Option<String>,
    pub device_info: Option<Properties>,
    pub duration_ms: Option<u64>,
    pub context: Option<Properties>,
    pub collector_id: Option<String>,
    pub collector_type: Option<String>,
    pub idempotency_key: Option<String>,
}

// ─── Messaging ──────────────────────────────────────────────────────────────

/// Targeting block for a messaging dispatch.
#[derive(Debug, Clone, Default, Serialize)]
pub struct MessagingTargeting {
    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
    pub targeting_type: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conditions: Option<Value>,
    #[serde(rename = "segmentIds", default, skip_serializing_if = "Option::is_none")]
    pub segment_ids: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    #[serde(
        rename = "subscriberIds",
        default,
        skip_serializing_if = "Option::is_none"
    )]
    pub subscriber_ids: Option<Vec<String>>,
}

/// Scheduling block for a messaging dispatch.
#[derive(Debug, Clone, Default, Serialize)]
pub struct MessagingScheduling {
    #[serde(
        rename = "deliveryType",
        default,
        skip_serializing_if = "Option::is_none"
    )]
    pub delivery_type: Option<String>,
    #[serde(rename = "scheduledAt", default, skip_serializing_if = "Option::is_none")]
    pub scheduled_at: Option<String>,
    #[serde(rename = "windowStart", default, skip_serializing_if = "Option::is_none")]
    pub window_start: Option<String>,
    #[serde(rename = "windowEnd", default, skip_serializing_if = "Option::is_none")]
    pub window_end: Option<String>,
}

/// Parameters for [`crate::messaging::Messaging::dispatch`].
#[derive(Debug, Clone)]
pub struct DispatchParams {
    /// Project UUID. Required by the messaging-dispatch edge function.
    pub project_id: String,
    /// Channel: `push | email | sms | in-app | live-activity`.
    pub channel: String,
    pub content: Properties,
    pub targeting: Option<MessagingTargeting>,
    pub scheduling: Option<MessagingScheduling>,
    pub template_id: Option<String>,
    /// Bearer JWT for the messaging-dispatch endpoint (Supabase session JWT +
    /// project membership), not the `x-api-key` used elsewhere.
    pub access_token: String,
}

// ─── LLM ──────────────────────────────────────────────────────────────────

/// Parameters for [`crate::llm::Llm::capture_trace`].
#[derive(Debug, Clone, Default)]
pub struct LlmTraceParams {
    pub trace_id: String,
    pub span_id: String,
    pub parent_span_id: Option<String>,
    pub model: String,
    pub input_text: String,
    pub output_text: String,
    pub prompt_tokens: Option<u64>,
    pub completion_tokens: Option<u64>,
    pub duration_ms: Option<u64>,
    pub cost_usd: Option<f64>,
    pub properties: Option<Properties>,
    pub metadata: Option<Properties>,
    /// ISO-8601 timestamp. Defaults to now if omitted.
    pub timestamp: Option<String>,
}