15 KiB
Executable File
source, confidence, namespace, last_synced, alwaysApply
| source | confidence | namespace | last_synced | alwaysApply |
|---|---|---|---|---|
| ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/abn-experiment-framework.mdc | 0.9 | work | 2026-04-28 | false |
A/B/N Experiment Framework
Overview
The DuckDuckGo browser includes a comprehensive A/B/N experiment framework that enables data-driven feature testing across iOS and macOS platforms. This framework allows you to safely experiment with new ideas while maintaining control groups and measuring impact.
Reference: Video of knowledge sharing session: ✓ A/B/N Experiment Framework
What is A/B/N Testing?
An A/B/N test is a method of experimenting with multiple variants of a feature (A, B, ... N) to determine which performs better. It's like traditional A/B testing but scaled up to support more than two groups.
Use Cases
You can use this framework to explore:
- UI/UX variations: Whether blue buttons outperform green buttons
- Content experiments: If showing pictures of cats 🐱 or dogs 🐶 boosts user retention
- Feature comparisons: Different implementations of the same functionality
- Performance optimization: Testing various algorithms or approaches
When to Use A/B/N Testing
✅ Use for:
- Comparing user behavior between two or more feature variants
- Validating hypotheses before rolling out changes to all users
- Safely experimenting with new ideas while maintaining control groups
- Measurable, impactful decisions with clear success metrics
❌ Don't use for:
- Simple bug fixes or obvious improvements
- Changes without measurable impact
- Features that can't be easily reversed
⚠️ Note: Not every change needs a test—reserve it for measurable, impactful decisions. This is typically decided in collaboration with ODRIs and Data Science.
Framework Architecture
Remote Configuration System
A/B/N experiments are supported via remote configuration on both macOS and iOS:
- Sub-features are used for experiments
- Parent features group related experiments
- Cohorts define the different variants
- Weights control user distribution
- Targets allow locale-based segmentation
Configuration Setup
1. Privacy Config Structure
Experiments are defined in the Privacy Configuration with this structure:
"amazingMacroFeature": {
"state": "enabled",
"features": {
"petsPictures": {
"state": "enabled",
"description": "This feature shows users pictures of cute pets",
"targets": [
{ "localeLanguage": "en", "localeCountry": "US" },
{ "localeLanguage": "fr", "localeCountry": "CA" }
],
"cohorts": [
{ "name": "cats", "weight": 1 },
{ "name": "dogs", "weight": 1 }
]
}
}
}
Configuration Elements
State Options
enabled: Visible to all usersinternal: Visible only to internal usersdisabled: Hidden from all users
Description
Explains the experiment's purpose for team reference.
Targets (Optional)
Specify user segments based on locale:
"targets": [
{ "localeLanguage": "en", "localeCountry": "US" },
{ "localeLanguage": "fr", "localeCountry": "CA" }
]
Cohorts
Define experiment variants:
name: Cohort identifier (e.g., "cats", "dogs")weight: Probability of assignment (normally 1 or 0)
Client Implementation
Step 1: Add Feature to PrivacyFeature (BSK)
Check for Existing Features
// In PrivacyFeature enum, check if parent feature exists
public enum PrivacyFeature: String, CaseIterable {
case amazingMacroFeature
// ... other features
}
// Add sub-feature to existing enum or create new one
public enum AmazingMacroFeatureSubfeatures: String, CaseIterable {
case petsPictures
// ... other sub-features
}
Add New Features
If the parent feature doesn't exist:
- Add it to the
PrivacyFeatureenum - Create a new sub-features enum
- Add your sub-feature to the enum
Step 2: Define Feature Flag
Add your experiment to the local FeatureFlag enum:
public enum FeatureFlag: String, CaseIterable {
case debugMenu
case sslCertificatesBypass
case maliciousSiteProtection
// ... existing flags
case petsPictures
public var cohortType: (any FeatureFlagCohortDescribing.Type)? {
switch self {
case .petsPictures:
return PetsPicturesCohort.self
default:
return nil
}
}
public enum PetsPicturesCohort: String, FeatureFlagCohortDescribing {
case cats
case dogs
}
public var source: FeatureFlagSource {
switch self {
// ... other cases
case .petsPictures:
return .remoteReleasable(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures))
}
}
public var supportsLocalOverriding: Bool {
switch self {
// ... other cases
case .petsPictures:
return true
}
}
}
Key Properties
cohortType: Links to experiment cohorts enum
- Must conform to
String, FeatureFlagCohortDescribing - Defines available variants (cats, dogs)
source: Defines feature flag toggle location
.disabled: Feature is off.internalOnly: Internal users only.remoteDevelopment: Development testing.remoteReleasable: Production experiments
supportsLocalOverriding: Enables debug menu overrides
true: Internal users can override cohort assignmentfalse: No local overrides allowed
Step 3: Implement Cohort Decision Logic
Request cohort assignment when needed:
// ✅ CORRECT: Request cohort only when decision is needed
guard let petsCohort = Application.appDelegate.featureFlagger.resolveCohort(for: .petsPictures) as? FeatureFlag.PetsPicturesCohort else {
return
}
switch petsCohort {
case .cats:
showCatPics()
case .dogs:
showDogPics()
}
⚠️ Important: Only request the cohort at the moment it's needed! This ensures accurate assignment and avoids data dilution.
Step 4: Handle Dynamic Cohort Changes
For features that need to respond to runtime cohort changes:
private func subscribeToPetsExperimentFeatureFlagChanges() {
guard let overridesHandler = Application.appDelegate.featureFlagger.localOverrides?.actionHandler as? FeatureFlagOverridesPublishingHandler<FeatureFlag> else {
return
}
overridesHandler.experimentFlagDidChangePublisher
.filter { $0.0 == .petsPictures }
.sink { (_, cohort) in
guard let newCohort = FeatureFlag.PetsPicturesCohort.cohort(for: cohort) else { return }
switch newCohort {
case .cats:
// IMMEDIATELY SHOW A CUTE CAT
case .dogs:
// IMMEDIATELY SHOW A CUTE DOG
}
}
.store(in: &cancellables)
}
Metrics and Analytics
Default Retention Metrics
The framework automatically tracks core engagement metrics without additional configuration:
1. Enrollment Pixel
Tracks when users join experiments:
Pixel Name: experiment_enroll_{experimentName}_{cohortName}
Parameters:
- enrollmentDate: Date in ET (YYYY-MM-DD format)
2. Search Activity Pixels
Monitors search behavior post-enrollment:
Pixel Name: experiment_metrics_{experimentName}_{cohortName}
Parameters:
- metric: "search"
- conversionWindowDays: Time frame (e.g., "1", "5-7")
- value: Number of searches performed
- enrollmentDate: Enrollment date (YYYY-MM-DD)
Predefined Tracking Windows:
- Value 1: Conversion windows [1, 2, 3, 4, 5, 6, 7, 5-7]
- Values 4, 6, 11, 21, 30: Conversion windows [5-7, 8-15]
3. App Usage Pixels
Tracks app engagement (launches, foregrounds, etc.):
Pixel Name: experiment_metrics_{experimentName}_{cohortName}
Parameters:
- metric: "app_use"
- conversionWindowDays: Time frame (e.g., "0", "1", "5-7")
- value: Number of app usage events
- enrollmentDate: Enrollment date (YYYY-MM-DD)
Predefined Tracking Windows:
- Value 1: Conversion windows [0, 1, 2, 3, 4, 5, 6, 7, 5-7]
- Values 4, 6, 11, 21, 30: Conversion windows [5-7, 8-15]
Custom Metrics
Track experiment-specific behaviors using PixelExperimentKit:
Import Required Framework
import PixelExperimentKit
Fire Custom Metric Pixels
// Method 1: Direct pixel firing
func fireExperimentPixel(
for subfeatureID: SubfeatureID,
metric: String,
conversionWindowDays: ConversionWindow,
value: String
)
// Method 2: Threshold-based pixel firing
func fireExperimentPixelIfThresholdReached(
for subfeatureID: SubfeatureID,
metric: String,
conversionWindowDays: ConversionWindow,
threshold: NumberOfCalls
)
Example: Button Click Tracking
// Track immediate button clicks
PixelKit.fireExperimentPixel(
for: "petsPictures",
metric: "adopt_button_clicks",
conversionWindowDays: 1...1,
value: "1"
)
// Track threshold-based clicks (fires after 5 clicks)
PixelKit.fireExperimentPixelIfThresholdReached(
for: "petsPictures",
metric: "button_clicks",
conversionWindowDays: 1...7,
threshold: 5
)
Custom Metric Examples
// Form completion tracking
PixelKit.fireExperimentPixel(
for: "petsPictures",
metric: "form_completed",
conversionWindowDays: 1...1,
value: "true"
)
// Feature adoption tracking
PixelKit.fireExperimentPixel(
for: "petsPictures",
metric: "set_as_default",
conversionWindowDays: 1...7,
value: "true"
)
// Error tracking
PixelKit.fireExperimentPixel(
for: "petsPictures",
metric: "error_occurred",
conversionWindowDays: 1...1,
value: "network_timeout"
)
Experiment Management
Stop Accepting New Users to a Cohort
To prevent new enrollments while maintaining existing users:
"cohorts": [
{ "name": "cats", "weight": 0 }, // No new users
{ "name": "dogs", "weight": 1 } // All new users go here
]
Behavior:
- Existing users: Remain in their assigned cohorts
- New users: Only assigned to cohorts with weight > 0
- No enrollment: Set all weights to 0
Remove Users from a Cohort
To completely remove a cohort and reassign users:
// Before: Two cohorts
"cohorts": [
{ "name": "cats", "weight": 1 },
{ "name": "dogs", "weight": 1 }
]
// After: Cats cohort removed
"cohorts": [
{ "name": "dogs", "weight": 1 }
]
Behavior:
- Existing users: Automatically reassigned to remaining cohorts
- New users: Assigned to available cohorts
- No cohorts: No users enrolled if all cohorts removed
Stop an Experiment
Complete cleanup requires both code and configuration changes:
1. Clean Up Code
// ❌ Remove experiment-specific logic
// switch petsCohort {
// case .cats:
// showCatPics()
// case .dogs:
// showDogPics()
// }
// ✅ Implement final chosen behavior
showDogPics() // Or whatever was determined to be the winner
2. Update Configuration
// Remove cohorts or entire sub-feature
"amazingMacroFeature": {
"state": "enabled",
"features": {
// "petsPictures": { ... } // Remove entire sub-feature
}
}
⚠️ Critical: Always remove code before removing configuration to prevent runtime errors.
Development and Testing
Feature Flag Sources for Development
Control experiment access during development:
Internal-Only Testing
public var source: FeatureFlagSource {
switch self {
case .petsPictures:
// Only internal users see this experiment
return .internalOnly(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures))
}
}
Development Random Assignment
public var source: FeatureFlagSource {
switch self {
case .petsPictures:
// Internal users get random assignment based on remote config
return .remoteDevelopment(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures))
}
}
Production Release
public var source: FeatureFlagSource {
switch self {
case .petsPictures:
// All users participate based on remote config
return .remoteReleasable(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures))
}
}
Local Overrides for Testing
Enable debug menu overrides for internal testing:
public var supportsLocalOverriding: Bool {
switch self {
case .petsPictures:
return true // Enables debug menu cohort selection
}
}
Usage:
- Open debug menu in internal builds
- Navigate to "Feature Flag Overrides"
- Select specific cohort for testing
- App immediately reflects cohort change
Best Practices
✅ DO
// Request cohort only when needed
guard let cohort = featureFlagger.resolveCohort(for: .petsPictures) as? FeatureFlag.PetsPicturesCohort else { return }
// Use meaningful cohort names
public enum PetsPicturesCohort: String, FeatureFlagCohortDescribing {
case cats // Clear, descriptive names
case dogs
}
// Track relevant custom metrics
PixelKit.fireExperimentPixel(
for: "petsPictures",
metric: "adoption_success",
conversionWindowDays: 1...7,
value: "true"
)
// Clean up after experiments
// Remove cohort logic and implement winning variant
❌ DON'T
// Don't request cohorts unnecessarily
let cohort = featureFlagger.resolveCohort(for: .petsPictures) // ❌ Called too early
// Don't use unclear cohort names
public enum TestCohort: String {
case a // ❌ Unclear what this represents
case b
}
// Don't forget to clean up
// Leaving experiment code after completion ❌
// Don't remove config before code
// Can cause runtime crashes ❌
🔒 Security and Privacy
// Don't log sensitive cohort information
Logger.debug("User assigned to cohort: \(cohort)") // ❌ Potential privacy issue
// Use privacy-safe logging
Logger.debug("Experiment cohort assigned") // ✅ Safe
// Don't store cohort assignments locally
UserDefaults.standard.set(cohort.rawValue, forKey: "cohort") // ❌ Privacy risk
Troubleshooting
Common Issues
Cohort Assignment Not Working
// Check feature flag setup
guard let cohort = featureFlagger.resolveCohort(for: .petsPictures) else {
Logger.error("Failed to resolve cohort for petsPictures")
return
}
Metrics Not Appearing
// Verify PixelExperimentKit import
import PixelExperimentKit
// Check subfeature ID matches config
PixelKit.fireExperimentPixel(
for: "petsPictures", // Must match config exactly
metric: "test_metric",
conversionWindowDays: 1...1,
value: "1"
)
Debug Menu Not Showing Experiment
public var supportsLocalOverriding: Bool {
switch self {
case .petsPictures:
return true // Must be true for debug menu
}
}
Validation Checklist
- Privacy config includes correct cohort names and weights
- FeatureFlag enum properly configured with cohort type
- Feature flag source matches intended audience
- Custom metrics fire at appropriate times
- Local overrides work in debug builds
- Experiment cleanup plan documented
This A/B/N experiment framework provides a robust, scalable solution for data-driven feature development in the DuckDuckGo browser, enabling safe experimentation while maintaining user privacy and providing comprehensive analytics.