diff --git a/README.md b/README.md index 9ae531e..453e4c7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ The official Rust SDK for [PostHog](https://posthog.com). See the main [PostHog docs](https://posthog.com/docs) for more information. +Upgrading from a 0.x release? Read the [1.0 migration guide](docs/migration-0.x-to-1.0.md). + SDK usage examples and code snippets live in the official documentation so they stay up to date. ## Documentation diff --git a/api/public-api.txt b/api/public-api.txt index ffa3555..02604d8 100644 --- a/api/public-api.txt +++ b/api/public-api.txt @@ -61,7 +61,7 @@ pub posthog_rs::EventStatus::Ok pub posthog_rs::EventStatus::Retry pub posthog_rs::EventStatus::Unknown pub posthog_rs::EventStatus::Warning -pub enum posthog_rs::FeatureFlagsResponse +#[non_exhaustive] pub enum posthog_rs::FeatureFlagsResponse pub posthog_rs::FeatureFlagsResponse::Legacy pub posthog_rs::FeatureFlagsResponse::Legacy::errors: core::option::Option> pub posthog_rs::FeatureFlagsResponse::Legacy::feature_flag_payloads: std::collections::hash::map::HashMap @@ -118,7 +118,7 @@ pub fn posthog_rs::CaptureFailure<'a>::event_results(&self) -> &std::collections pub fn posthog_rs::CaptureFailure<'a>::historical_migration(&self) -> bool pub fn posthog_rs::CaptureFailure<'a>::request_id(&self) -> core::option::Option<&uuid::Uuid> pub fn posthog_rs::CaptureFailure<'a>::status(&self) -> core::option::Option -pub struct posthog_rs::CaptureResponse +#[non_exhaustive] pub struct posthog_rs::CaptureResponse pub posthog_rs::CaptureResponse::results: std::collections::hash::map::HashMap #[non_exhaustive] pub struct posthog_rs::CaptureSummary impl posthog_rs::CaptureSummary @@ -238,7 +238,7 @@ pub fn posthog_rs::Event::remove_prop(&mut self, &str) -> core::option::Option(&mut self, chrono::datetime::DateTime) -> core::result::Result<(), posthog_rs::Error> where Tz: chrono::offset::TimeZone pub fn posthog_rs::Event::set_uuid(&mut self, uuid::Uuid) pub fn posthog_rs::Event::with_flags(&mut self, &posthog_rs::FeatureFlagEvaluations) -> &mut Self -pub struct posthog_rs::EventResult +#[non_exhaustive] pub struct posthog_rs::EventResult pub posthog_rs::EventResult::details: core::option::Option pub posthog_rs::EventResult::result: posthog_rs::EventStatus pub struct posthog_rs::FeatureFlag @@ -282,13 +282,13 @@ pub fn posthog_rs::FlagCache::new() -> Self pub fn posthog_rs::FlagCache::update(&self, posthog_rs::LocalEvaluationResponse) impl core::default::Default for posthog_rs::FlagCache pub fn posthog_rs::FlagCache::default() -> Self -pub struct posthog_rs::FlagDetail +#[non_exhaustive] pub struct posthog_rs::FlagDetail pub posthog_rs::FlagDetail::enabled: bool pub posthog_rs::FlagDetail::key: alloc::string::String pub posthog_rs::FlagDetail::metadata: core::option::Option pub posthog_rs::FlagDetail::reason: core::option::Option pub posthog_rs::FlagDetail::variant: core::option::Option -pub struct posthog_rs::FlagMetadata +#[non_exhaustive] pub struct posthog_rs::FlagMetadata pub posthog_rs::FlagMetadata::description: core::option::Option pub posthog_rs::FlagMetadata::has_experiment: core::option::Option pub posthog_rs::FlagMetadata::id: u64 @@ -302,7 +302,7 @@ pub fn posthog_rs::FlagPoller::start(&mut self) pub fn posthog_rs::FlagPoller::stop(&mut self) impl core::ops::drop::Drop for posthog_rs::FlagPoller pub fn posthog_rs::FlagPoller::drop(&mut self) -pub struct posthog_rs::FlagReason +#[non_exhaustive] pub struct posthog_rs::FlagReason pub posthog_rs::FlagReason::code: alloc::string::String pub posthog_rs::FlagReason::condition_index: core::option::Option pub posthog_rs::FlagReason::description: core::option::Option @@ -330,11 +330,13 @@ pub posthog_rs::LocalEvaluationConfig::secret_key: alloc::string::String impl<'a> posthog_rs::LocalEvaluationFailure<'a> pub fn posthog_rs::LocalEvaluationFailure<'a>::error(&self) -> &posthog_rs::Error pub fn posthog_rs::LocalEvaluationFailure<'a>::status(&self) -> core::option::Option -pub struct posthog_rs::LocalEvaluationResponse +#[non_exhaustive] pub struct posthog_rs::LocalEvaluationResponse pub posthog_rs::LocalEvaluationResponse::cohorts: std::collections::hash::map::HashMap pub posthog_rs::LocalEvaluationResponse::flags: alloc::vec::Vec pub posthog_rs::LocalEvaluationResponse::group_type_mapping: std::collections::hash::map::HashMap pub posthog_rs::LocalEvaluationResponse::minimal_flag_called_events: bool +impl posthog_rs::LocalEvaluationResponse +pub fn posthog_rs::LocalEvaluationResponse::new(alloc::vec::Vec) -> Self pub struct posthog_rs::LocalEvaluator impl posthog_rs::LocalEvaluator pub fn posthog_rs::LocalEvaluator::cache(&self) -> &posthog_rs::FlagCache diff --git a/docs/migration-0.x-to-1.0.md b/docs/migration-0.x-to-1.0.md new file mode 100644 index 0000000..1134ad2 --- /dev/null +++ b/docs/migration-0.x-to-1.0.md @@ -0,0 +1,107 @@ +# Migrating from posthog-rs 0.x to 1.0 + +Version 1.0 makes the V1 analytics endpoint the SDK's only capture path and removes APIs that were already deprecated in 0.x. This guide describes the changes currently staged on the `v1` branch. + +## Cargo features + +Remove `capture-v1` from your dependency features. Capture no longer needs a feature flag. + +```toml +# 0.x while opting into V1 capture +posthog-rs = { version = "0.25", features = ["capture-v1"] } + +# 1.0 +posthog-rs = "1" +``` + +The default features remain the async client and error tracking. Use `default-features = false` for the blocking client. + +```toml +# Async client with error tracking +posthog-rs = "1" + +# Blocking client without error tracking +posthog-rs = { version = "1", default-features = false } +``` + +The V0 capture implementation and its `/i/v0/e/` and batch plumbing have been removed. `Endpoint::Batch` no longer exists, and `Endpoint::Capture` now resolves to `/i/v1/analytics/events`. + +## Capture behavior + +The regular `capture` and `capture_batch` methods remain fire-and-forget. They enqueue events for the background worker, which batches and retries delivery. `capture_immediate` and `capture_batch_immediate` still bypass the queue and return a `CaptureSummary` after reaching a terminal result. + +All event-producing SDK paths now use the same capture endpoint, including error tracking, `$feature_flag_called` events, historical migration, `before_send`, and terminal failures reported through `on_error`. + +### Compression + +`CaptureCompression::Gzip`, `Deflate`, `Br`, and `Zstd` now all apply their corresponding `Content-Encoding`. In older default V0 builds, only gzip was supported and selecting another variant could send an uncompressed body. Check any proxy or WAF in front of PostHog before enabling Brotli or Zstandard. + +### Retry and persistence results + +The V1 endpoint returns a result for each event. The SDK retries transient request failures and only the events with retryable results from a partial response. + +`CaptureSummary::not_persisted()` and `CaptureSummary::all_persisted()` now use those per-event results. In the V0 path they reported a successful `2xx` as fully persisted without per-event confirmation. Applications that advance durable state after `capture_immediate` should check both that `submitted()` equals the number of intended events and that `all_persisted()` is true, because disabled clients and fully `before_send`-filtered batches submit no events but still report `all_persisted()` as true. + +HTTP 429 is not a retryable V1 capture status. The V1 service uses HTTP 402 for billing limits and per-event `drop` or `warning` results in successful responses. `Retry-After` is still honored for retryable failures and retry results. + +### Renamed capture response type + +Rename `V1ErrorResponse` to `CaptureErrorResponse`: + +```rust +// 0.x with capture-v1 +let response: Option<&posthog_rs::V1ErrorResponse> = failure.error_response(); + +// 1.0 +let response: Option<&posthog_rs::CaptureErrorResponse> = failure.error_response(); +``` + +## Feature flags + +The deprecated single-flag methods have been removed: + +- `is_feature_enabled` +- `get_feature_flag` +- `get_feature_flag_payload` +- `get_feature_flags` + +Call `evaluate_flags` once and read from the returned snapshot instead: + +```rust +use posthog_rs::EvaluateFlagsOptions; + +let flags = client + .evaluate_flags("user-123", EvaluateFlagsOptions::default()) + .await?; + +if flags.is_enabled("new-checkout") { + // Use the enabled feature. +} + +let variant = flags.get_flag("checkout-variant"); +let payload = flags.get_flag_payload("checkout-variant"); +``` + +The blocking client uses the same calls without `.await`. + +`EvaluateFlagsOptions` is now non-exhaustive. Construct it with `Default` and assign the fields you need instead of using a struct literal: + +```rust +let mut options = EvaluateFlagsOptions::default(); +options.disable_geoip = Some(true); +options.flag_keys = Some(vec!["new-checkout".to_string()]); +``` + +## Local evaluation credentials + +Use `secret_key` terminology throughout configuration. The builder's deprecated `personal_api_key` alias has been removed, and `LocalEvaluationConfig::personal_api_key` is now `secret_key`. + +```rust +let options = posthog_rs::ClientOptionsBuilder::default() + .api_key("phc_project_token") + .secret_key("phs_project_secret") + .enable_local_evaluation(true) + .build()?; +``` + +`secret_key` accepts either a project secret key (`phs_...`) or a personal API key (`phx_...`). Do not send this key as an event property. diff --git a/src/capture_event.rs b/src/capture_event.rs index b0ca2b2..b368047 100644 --- a/src/capture_event.rs +++ b/src/capture_event.rs @@ -172,6 +172,7 @@ pub enum EventStatus { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct EventResult { pub result: EventStatus, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -179,6 +180,7 @@ pub struct EventResult { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct CaptureResponse { pub results: HashMap, } diff --git a/src/client/async_client.rs b/src/client/async_client.rs index e7d2120..8b7c9c1 100644 --- a/src/client/async_client.rs +++ b/src/client/async_client.rs @@ -480,7 +480,7 @@ impl Client { self.send_immediate(events, historical_migration).await } - /// Inline V1 capture: prepare once via the shared sans-IO helpers, then loop + /// Inline capture: prepare once via the shared sans-I/O helpers, then loop /// send/classify, awaiting `tokio::time::sleep` between retries. The setup and /// classification are shared with the blocking client; only this loop differs. async fn send_immediate( diff --git a/src/client/blocking.rs b/src/client/blocking.rs index 991babe..e0694d8 100644 --- a/src/client/blocking.rs +++ b/src/client/blocking.rs @@ -461,7 +461,7 @@ impl Client { self.send_immediate(events, historical_migration) } - /// Inline V1 capture: prepare once via the shared sans-IO helpers, then loop + /// Inline capture: prepare once via the shared sans-I/O helpers, then loop /// send/classify, sleeping on the calling thread between retries. The setup and /// classification are shared with the async client; only this loop differs. fn send_immediate( diff --git a/src/client/capture.rs b/src/client/capture.rs index 319161c..cb38aad 100644 --- a/src/client/capture.rs +++ b/src/client/capture.rs @@ -1,4 +1,4 @@ -//! Shared, runtime-agnostic helpers for the V1 capture pipeline. +//! Shared, runtime-agnostic helpers for the capture pipeline. //! Each client keeps only the I/O; this module owns everything else. use std::{collections::HashMap, time::Duration}; @@ -9,7 +9,7 @@ use tracing::debug; use uuid::Uuid; use super::retry::{backoff_duration, is_retryable_status}; -// Re-exported so the V1 capture loops in the client modules can reach them as +// Re-exported so the capture loops in the client modules can reach them as // `capture::parse_retry_after` / `capture::Step`. pub(crate) use super::retry::{parse_retry_after, Step}; use super::{ @@ -141,7 +141,7 @@ pub(crate) fn maybe_compress( // Inline (immediate) capture preparation // --------------------------------------------------------------------------- -/// Everything an inline immediate V1 capture needs after event preparation: +/// Everything an inline immediate capture needs after event preparation: /// built once, then reused across retry attempts. The async and blocking /// clients share this (it is I/O-free) and keep only the send loop. pub(crate) struct Prepared { @@ -153,7 +153,7 @@ pub(crate) struct Prepared { pub(crate) submitted: usize, } -/// Prepare an inline immediate V1 capture: apply client defaults + `before_send`, +/// Prepare an inline immediate capture: apply client defaults + `before_send`, /// then build the wire events and the per-request identity (request id, /// `created_at`, URL). Returns `None` when nothing survives filtering (an empty /// or fully `before_send`-dropped batch), so the caller returns a default @@ -270,7 +270,7 @@ pub(crate) fn after_transport_error( request_id = %request_id, attempt, error = %err_msg, - "V1 capture request failed, will retry" + "Capture request failed, will retry" ); Step::Backoff(backoff_duration(opts, attempt, None)) } @@ -300,7 +300,7 @@ pub(crate) fn after_response( request_id = %request_id, attempt, results = ?result_counts, - "V1 capture batch response" + "Capture batch response" ); } @@ -329,7 +329,7 @@ pub(crate) fn after_response( attempt, status, error = %error_desc, - "V1 capture request failed, will retry" + "Capture request failed, will retry" ); if attempt >= opts.max_capture_attempts { diff --git a/src/client/mod.rs b/src/client/mod.rs index 59eb0ae..a101567 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -236,8 +236,7 @@ pub struct ClientOptions { pub(crate) shutdown_timeout_ms: u64, /// Optional request-body compression. When `None` (default), bodies are - /// sent uncompressed. The V0 pipeline supports `Gzip` only; V1 supports all - /// variants. + /// sent uncompressed. The capture endpoint supports all variants. #[builder(default, setter(strip_option))] pub(crate) capture_compression: Option, @@ -268,10 +267,9 @@ pub struct ClientOptions { /// Resolved client-level default properties for capture requests. /// -/// Built once from [`ClientOptions`] and threaded through all event-producing -/// paths (V0 capture, V0 flag-called host, V1 capture) so each default is -/// applied in exactly one place with caller-wins (`entry().or_insert`) -/// semantics. +/// Built once from [`ClientOptions`] and threaded through every event-producing +/// path so each default is applied in exactly one place with caller-wins +/// (`entry().or_insert`) semantics. #[derive(Debug, Clone, Copy)] pub(crate) struct CaptureDefaults { pub(crate) disable_geoip: bool, diff --git a/src/client/on_error.rs b/src/client/on_error.rs index 74c31ae..a1c59bd 100644 --- a/src/client/on_error.rs +++ b/src/client/on_error.rs @@ -127,14 +127,14 @@ impl<'a> CaptureFailure<'a> { self.historical_migration } - /// The V1 capture `posthog-request-id` of the final attempt, when one was - /// sent. `None` for a serialization failure (no request reached the wire) - /// and on the v0 pipeline (which has no request id). + /// The capture `posthog-request-id` of the final attempt, when one was + /// sent. `None` for a serialization failure where no request reached the + /// wire. pub fn request_id(&self) -> Option<&Uuid> { self.request_id } - /// Per-event server verdicts for the batch (V1 capture pipeline only). + /// Per-event server verdicts for the capture batch. /// /// Maps event UUID to its [`EventResult`]. Includes **all** verdicts the /// batch collected — persisted (`ok`/`warning`) as well as lost @@ -148,7 +148,7 @@ impl<'a> CaptureFailure<'a> { self.results } - /// The structured error body returned by the V1 capture backend on a + /// The structured error body returned by the capture backend on a /// non-`2xx` response (`error`, `error_description`, `error_uri`), when the /// body parsed as one. `None` for a transport error, a `2xx`, or an /// unrecognizable body — the raw body remains available via diff --git a/src/client/retry.rs b/src/client/retry.rs index d80f6b4..2e9cb86 100644 --- a/src/client/retry.rs +++ b/src/client/retry.rs @@ -8,9 +8,9 @@ use reqwest::header::HeaderMap; use super::ClientOptions; use crate::error::Error; -/// Outcome of one capture attempt, computed without any I/O so both the async -/// and blocking clients (and both V0 and V1) can share the decision logic and -/// keep only the transport-specific loop. +/// Outcome of one capture attempt, computed without any I/O so the async and +/// blocking clients can share the decision logic and keep only the +/// transport-specific loop. #[derive(Debug)] pub(crate) enum Step { Done, @@ -402,7 +402,7 @@ mod tests { ); } - // -- v0 sans-IO decisions ------------------------------------------------ + // -- Feature flag sans-I/O decisions ------------------------------------- /// The schedule the call sites actually produce. Guards the `attempt + 1` /// off-by-one: the first retry must wait exactly `retry_initial_backoff_ms` diff --git a/src/feature_flags.rs b/src/feature_flags.rs index 65b61ce..9ff172a 100644 --- a/src/feature_flags.rs +++ b/src/feature_flags.rs @@ -317,6 +317,7 @@ pub struct MultivariateVariant { /// legacy format (simple flag values and payloads). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] +#[non_exhaustive] pub enum FeatureFlagsResponse { /// v2 API format from `/flags/?v=2` endpoint V2 { @@ -411,6 +412,7 @@ impl FeatureFlagsResponse { /// Returned by the `/flags/?v=2` endpoint with extended information about why a /// flag evaluated to a particular value. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct FlagDetail { /// The feature flag key pub key: String, @@ -428,6 +430,7 @@ pub struct FlagDetail { /// Explains why a feature flag evaluated to a particular value. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct FlagReason { /// Reason code (e.g., "condition_match", "out_of_rollout_bound") pub code: String, @@ -441,6 +444,7 @@ pub struct FlagReason { /// Metadata about a feature flag from the PostHog server. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct FlagMetadata { /// Unique identifier for this flag pub id: u64, diff --git a/src/local_evaluation.rs b/src/local_evaluation.rs index 4a46868..7b29ecf 100644 --- a/src/local_evaluation.rs +++ b/src/local_evaluation.rs @@ -57,6 +57,7 @@ fn report_local_eval_error(hooks: &[OnErrorHook], status: Option, error: &E /// Contains feature flag definitions, group type mappings, and cohort definitions /// that can be cached locally for flag evaluation without server round-trips. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct LocalEvaluationResponse { /// List of feature flag definitions pub flags: Vec, @@ -73,6 +74,21 @@ pub struct LocalEvaluationResponse { pub minimal_flag_called_events: bool, } +impl LocalEvaluationResponse { + /// Create a local-evaluation response from feature flag definitions. + /// + /// Group mappings, cohorts, and minimized flag-called events default to + /// empty or disabled and can be assigned through their public fields. + pub fn new(flags: Vec) -> Self { + Self { + flags, + group_type_mapping: HashMap::new(), + cohorts: HashMap::new(), + minimal_flag_called_events: false, + } + } +} + /// A cohort definition for local evaluation. /// /// Cohorts are groups of users defined by property filters, used for diff --git a/tests/test_local_evaluation.rs b/tests/test_local_evaluation.rs index 887d485..08c7c87 100644 --- a/tests/test_local_evaluation.rs +++ b/tests/test_local_evaluation.rs @@ -39,12 +39,7 @@ fn test_local_evaluation_basic() { }; // Update cache with the flag - let response = LocalEvaluationResponse { - flags: vec![flag], - group_type_mapping: HashMap::new(), - cohorts: HashMap::new(), - minimal_flag_called_events: false, - }; + let response = LocalEvaluationResponse::new(vec![flag]); cache.update(response); // Test evaluation @@ -302,12 +297,7 @@ fn test_local_evaluation_with_properties() { }; // Update cache - let response = LocalEvaluationResponse { - flags: vec![flag], - group_type_mapping: HashMap::new(), - cohorts: HashMap::new(), - minimal_flag_called_events: false, - }; + let response = LocalEvaluationResponse::new(vec![flag]); cache.update(response); // Test with matching properties @@ -731,12 +721,7 @@ fn test_cache_operations() { }, ]; - let response = LocalEvaluationResponse { - flags: flags.clone(), - group_type_mapping: HashMap::new(), - cohorts: HashMap::new(), - minimal_flag_called_events: false, - }; + let response = LocalEvaluationResponse::new(flags.clone()); cache.update(response); @@ -1274,12 +1259,9 @@ fn cache_with(flag: FeatureFlag) -> FlagCache { let cache = FlagCache::new(); let mut group_type_mapping = HashMap::new(); group_type_mapping.insert("0".to_string(), "company".to_string()); - cache.update(LocalEvaluationResponse { - flags: vec![flag], - group_type_mapping, - cohorts: HashMap::new(), - minimal_flag_called_events: false, - }); + let mut response = LocalEvaluationResponse::new(vec![flag]); + response.group_type_mapping = group_type_mapping; + cache.update(response); cache }