Skip to content

Conversation

@fern-support
Copy link

Summary

This bundle brings the following to the PostHog Rust SDK, making the SDK feature-complete and developer-friendly.

  • feature flags with local evaluation
  • improved error handling
  • flexible endpoint configuration

Feature Flags with Local Evaluation

This adds feature flag support to the PostHog Rust SDK, including local evaluation, automatic $feature_flag_called event tracking, and extensive test coverage.

Key Features

  • Full feature flag support: Boolean flags, multivariate flags, and custom payloads
  • Local evaluation: Background polling with cached flag definitions for optimal performance
  • Automatic event tracking: $feature_flag_called events with intelligent deduplication
  • Advanced configuration: Flexible options via ClientOptionsBuilder
  • Dual client support: Both async and blocking implementations
  • Property-based targeting: User attributes, cohorts, and batch evaluation
  • Thread-safe implementation: Graceful error handling and concurrent access support

Core Implementation

New Files:

  • src/feature_flags.rs: Core feature flag evaluation logic with property matching operators
  • src/local_evaluation.rs: Local caching and evaluation system with background polling
  • src/endpoints.rs: PostHog API endpoint definitions

Enhanced Files:

  • src/client/blocking.rs: Feature flag methods with event tracking
  • src/client/async_client.rs: Async feature flag support
  • src/client/mod.rs: Shared configuration and client options

Event Tracking System

  • Automatic $feature_flag_called event capture for all flag evaluations
  • Deduplication logic per distinct_id + flag_key + response combination
  • Infinite loop prevention (doesn't evaluate flags for its own events)
  • Thread-safe tracker using Arc<RwLock> with configurable size limits (default: 10,000 entries)
  • Rich metadata support including payloads, reasons, versions, and request IDs
  • Configurable globally or per-call via send_feature_flag_events parameter

Testing & Quality

Test Suites:

  • tests/test_async.rs: Comprehensive async client tests
  • tests/test_blocking.rs: Blocking client tests
  • tests/test_local_evaluation.rs: Local evaluation tests
  • tests/test.rs: E2E tests with real PostHog API

Examples:

  • examples/feature_flags.rs: A/B testing, property targeting, multivariate flags
  • examples/local_evaluation.rs: Local evaluation patterns and performance
  • examples/feature_flag_events.rs: Event tracking demonstrations
  • examples/advanced_config.rs: Configuration options

Strongly Typed Errors

Refactors the PostHog Rust SDK's error handling for better developer experience and maintainability.

Architecture Changes

Before:

pub enum Error {
    Connection(String),           // Generic string
    Serialization(String),        // No structure
    AlreadyInitialized,
    NotInitialized,
    InvalidTimestamp(String),
}

After:

pub enum Error {
    Transport(TransportError),      // Network, HTTP, timeouts
    Validation(ValidationError),    // Data integrity
    Initialization(InitializationError), // Config issues
}

Benefits:

  • Semantic categories with helper methods (is_retryable(), is_client_error())
  • Structured data with automatic conversions
  • Used thiserror for better error messages and source chain support (#[from])

Key Improvements

1. Type-Safe Pattern Matching

Before:

Err(Error::Connection(msg)) => {
    if msg.contains("timeout") { /* fragile string parsing */ }
    if msg.contains("401") { /* parse status from string */ }
}

After:

match error {
    Error::Transport(TransportError::Timeout(duration)) => {
        eprintln!("Timed out after {:?}", duration);
    }
    Error::Transport(TransportError::HttpError(401, _)) => {
        eprintln!("Invalid API key");
    }
    Error::Validation(ValidationError::BatchSizeExceeded { size, max }) => {
        split_into_chunks(size, max);
    }
    _ => {}
}

2. Structured Error Data

Before:

Err(Error::Serialization("Batch size 1500 exceeds max 1000".to_string()))
// Must parse "1500" and "1000" from string

After:

Err(ValidationError::BatchSizeExceeded { size: 1500, max: 1000 })
// Direct access to structured data
if let Err(Error::Validation(ValidationError::BatchSizeExceeded { size, max })) = result {
    let chunks = (size + max - 1) / max;
    for chunk in events.chunks(max) {
        client.batch_send(chunk).await?;
    }
}

3. Automatic Error Conversion

Before:

let body = serde_json::to_string(&event)
    .map_err(|e| Error::Serialization(e.to_string()))?; // Manual
let response = client.post(url).send().await
    .map_err(|e| Error::Connection(e.to_string()))?; // Manual

After:

let body = serde_json::to_string(&event)
    .map_err(|e| ValidationError::SerializationFailed(e.to_string()))?;
let response = client.post(url).send().await?; // Automatic via From trait

4. Intelligent Retry Logic

Before:

match client.capture("user", "event").await {
    Err(e) => println!("Error: {}", e), // Should we retry? No idea.
    _ => {}
}

After:

match client.capture("user", "event").await {
    Err(e) if e.is_retryable() => {
        // Automatic detection: timeouts, 5xx, 429
        tokio::time::sleep(Duration::from_secs(2)).await;
        client.capture("user123", "signup").await?;
    }
    Err(e) => eprintln!("Permanent error: {}", e), // Don't retry
    _ => {}
}

Backward Compatibility

No breaking changes - This is a backward-compatible refactor. All existing code will continue to work with deprecation warnings.

Tests added:

  • tests/api_compatibility.rs: API compatibility validation
  • tests/backward_compatibility.rs: Backward compatibility verification

Smart Endpoint Configuration

Implements a flexible configuration system for the PostHog Rust SDK that accepts base URLs and automatically handles endpoint paths. This enables proper use of both single-event and batch endpoints.

Architecture Transformation

Previous approach: Required full endpoint URL (e.g., https://us.i.posthog.com/i/v0/e/)

  • Prevented simultaneous use of /i/v0/e/ and /batch/ endpoints
  • Limited extensibility for future endpoints

New approach: Accept base URL and normalize automatically

  • Users provide: https://us.posthog.com
  • SDK appends: /i/v0/e/ for single events, /batch/ for batches
  • Backward compatible: still accepts full URLs and strips paths

Before

SDK required full endpoint URLs, preventing simultaneous use of different endpoints:

// Old way - had to specify full path
let client = posthog_rs::client("phc_...");
client.set_api_endpoint("https://us.i.posthog.com/i/v0/e/");

// Problem: Can only use single OR batch endpoint, not both!
// If you set /i/v0/e/, batch requests fail
// If you set /batch/, single event requests fail

This design prevented the SDK from using both /i/v0/e/ (single events) and /batch/ (batch events) simultaneously.

After

Accept base URL, SDK appends the correct path automatically:

// New way - provide just the hostname
let options = ClientOptionsBuilder::new()
    .api_key("phc_...")
    .api_endpoint("https://eu.posthog.com")
    .build()?;

// SDK now automatically uses:
// - https://eu.posthog.com/i/v0/e/ for single events
// - https://eu.posthog.com/batch/ for batches

Backward Compatibility

// Old format still works - path gets stripped automatically
let options = ClientOptionsBuilder::new()
    .api_key("phc_...")
    .api_endpoint("https://us.i.posthog.com/i/v0/e/")
    .build()?;
// Normalized to: https://us.i.posthog.com
// Then SDK appends correct paths as needed

// Works with batch endpoint too
let options = ClientOptionsBuilder::new()
    .api_key("phc_...")
    .api_endpoint("https://us.i.posthog.com/batch/")
    .build()?;
// Also normalized to: https://us.i.posthog.com

Breaking Changes

None - All changes are backward compatible. Existing code will continue to work, though some patterns are deprecated with warnings to guide migration to the new APIs.

@fern-support fern-support marked this pull request as draft November 14, 2025 19:43
@fern-support fern-support changed the title feat: add feature flags with local evaluation, strongly typed errors, and smart endpoint configuration 🌿Fern -- feat: add feature flags with local evaluation, strongly typed errors, and smart endpoint configuration Nov 14, 2025
@fern-support fern-support marked this pull request as ready for review November 17, 2025 16:21
Copy link
Contributor

@oliverb123 oliverb123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more small pieces on shaping the public api, and removing some debug logging, but overall looks good


/// Configuration options for the PostHog client.
#[derive(Debug, Clone)]
pub struct ClientOptions {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should expose far less of this - none of these fields should be pub, maybe pub(crate) but definitely not part of the SDKs public API

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True made all of them pub(crate)

src/lib.rs Outdated
pub use client::ClientOptionsBuilderError;

// Endpoints
pub use endpoints::{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this can also stay private I think - stuff like Endpoint and EndpointManager you're being forced to expose because of all the pub fields in the config struct, but they're implementation details. At most, here, I'd expose a constant for the default host, plus the US and EU hostnames.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only export

pub use endpoints::{DEFAULT_HOST, EU_INGESTION_ENDPOINT, US_INGESTION_ENDPOINT};

everything is pub(crate) now

pub endpoint_manager: EndpointManager,
}

impl ClientOptions {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, the vast majority of this can be pub(crate) or not pub at all.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, all of them are pub(crate)


(Some(LocalEvaluator::new(cache)), Some(poller))
} else {
eprintln!("[FEATURE FLAGS] Local evaluation enabled but personal_api_key not set");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should never hit this case - it should be impossible to set enable_local_evaluation to true without having also provided a personal api key, and the client options builder should return an error from build if that does happen.

Aside from that, logging straight to eprintlin is also a totally inappropriate thing to do - we should either hook into tracing, or fail silently, but in practice as mentioned above, this invalid state should be unreachable, and you should be able to safely unwrap on the key in the present of enable_local_evaluation

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the checks and error handling before setting enable_local_evaluation to true


/// Build the ClientOptions, validating all fields
pub fn build(self) -> Result<ClientOptions, Error> {
#[allow(deprecated)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you returning the deprecated error enum here? I feel like we should be returning one of the new error types?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated with new Error enums

let mut event = Event::new("$feature_flag_called", distinct_id);

// Add required properties
event.insert_prop("$feature_flag", flag_key).ok();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this function return a result, and use the question mark operator here and below

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both async and sync will return Result<(), Error> now,
and use ?.

// Flag not found locally, fall through to API
}
Err(e) => {
eprintln!(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should never log like this. Either fallback to the api silently, or integrate tracing, but for now I'd go with silent. Applies to all later uses too

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed all logs, will fallback the api silently

}

/// Get a feature flag payload for a user
pub async fn get_feature_flag_payload<K: Into<String>, D: Into<String>>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should not be part of the public api

Copy link

@iamnamananand996 iamnamananand996 Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated to pub(crate)

}

/// Get a specific feature flag value with control over event capture
pub async fn get_feature_flag_with_options<K: Into<String>, D: Into<String>>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this not part of the public API for now, only pub(crate) or private. Users should only control whether the flag event is sent or not in the config.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update to pub(crate)

@@ -1,63 +1,516 @@
use std::collections::{HashMap, HashSet};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming this is 1:1 with the async client above, most of the same comments probably apply re: particular functions being public/private and removing eprintln invocations.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all chnages will reflect in both async and sync code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants