A Swift analytics abstraction library that provides a unified interface for multiple analytics providers.
Phoenix abstracts away the complexity of working with different analytics SDKs by providing a single, type-safe API. While originally built around Firebase Analytics, Phoenix currently supports PostHog with additional providers planned for the future.
- Unified API - Single interface for all analytics operations
- Type Safety - Strongly-typed events, parameters, and screens
- SwiftUI Integration - Environment values and view modifiers for seamless tracking
- Feature Flags - A/B testing and feature flag support with type-safe variants
- Commerce Tracking - Built-in StoreKit transaction logging
- Crash Reporting - MetricKit integration for crash diagnostics
- iOS 16.0+ / macOS 13.0+ / tvOS 16.0+
- Swift 6.0+
Add Phoenix to your Package.swift:
dependencies: [
.package(url: "https://github.com/hidden-spectrum/phoenix.git", from: "0.1.0")
]Then add the targets you need:
.target(
name: "YourApp",
dependencies: [
"Phoenix", // Core library (required)
"PhoenixPH", // PostHog provider
]
)import Phoenix
import PhoenixPH
import PostHog
// Configure PostHog
let config = PostHogConfig(apiKey: "your-api-key")
config.host = "https://app.posthog.com"
// Create Phoenix instance
let phoenix = Phoenix(provider: PostHogAnalyticsProvider(with: config))@main
struct MyApp: App {
init() {
phoenix.appDidFinishLaunching()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.phoenix, phoenix)
}
}
}struct ContentView: View {
@Environment(\.phoenix) private var phoenix
var body: some View {
Button("Purchase") {
phoenix.logEvent(.purchaseTapped)
}
}
}Phoenix uses type-safe structs that you extend with static constants. This provides autocomplete, compile-time safety, and a centralized place to define your analytics taxonomy.
extension AnalyticsEvent {
// Authentication
static let loginTapped = Event("login_tapped")
static let signupCompleted = Event("signup_completed")
static let logoutTapped = Event("logout_tapped")
// Navigation
static let tabSelected = Event("tab_selected")
static let backTapped = Event("back_tapped")
// Content
static let itemViewed = Event("item_viewed")
static let itemShared = Event("item_shared")
// Events with default parameters
static func searchPerformed(query: String) -> Event {
Event("search_performed", parameters: [
.searchTerm: query
])
}
static func itemAddedToCart(itemId: String, price: Double) -> Event {
Event("item_added_to_cart", parameters: [
.contentId: itemId,
.price: price
])
}
}extension AnalyticsScreen {
static let home = Screen("home")
static let search = Screen("search")
static let profile = Screen("profile")
static let settings = Screen("settings")
static let onboarding = Screen("onboarding")
static let checkout = Screen("checkout")
static let productDetail = Screen("product_detail")
}extension AnalyticsParameter {
static let itemCategory = Parameter("item_category")
static let subscriptionTier = Parameter("subscription_tier")
static let referralSource = Parameter("referral_source")
static let experimentGroup = Parameter("experiment_group")
}extension AnalyticsElementName {
static let submitButton: ElementName = "submit_button"
static let cancelButton: ElementName = "cancel_button"
static let shareButton: ElementName = "share_button"
static let productCell: ElementName = "product_cell"
static let settingsRow: ElementName = "settings_row"
}extension AnalyticsFeatureFlag where Value == Bool {
static let newCheckoutFlow = Feature("new_checkout_flow")
static let darkModeEnabled = Feature("dark_mode_enabled")
static let premiumFeatures = Feature("premium_features")
}Declare the payload type on the flag so callers cannot choose a different return type:
struct CheckoutConfig: Decodable, Sendable {
let title: String
let maximumItems: Int
}
extension AnalyticsFeatureFlag where Value == CheckoutConfig {
static let checkoutConfig = Feature("checkout_config")
}
let config = phoenix.featureFlagValue(.checkoutConfig) // CheckoutConfig?
let title = config?.title ?? "Checkout"AnalyticsFeatureFlag<Value> requires only Sendable. The base
featureFlagValue overload casts the provider payload directly to Value.
An explicit Value: Decodable overload decodes JSON objects, arrays, and scalar
values such as String, Int, and Bool. A payload containing only 123 can
therefore be read with AnalyticsFeatureFlag<Int>("item_limit").
Missing or mismatched payloads return nil. Providers without payload support
also return nil.
Generic wrappers must retain the Decodable constraint to select the decoding
overload; overload selection uses the constraints known at the call site.
featureFlagValue reads the payload independently of the enabled state. Use
featureEnabled(.checkoutConfig) when the configuration should only apply to
enabled flags. featureFlagValue on a Bool flag reads its boolean payload, not
its enabled state. Call reloadFeatureFlags() before reading when you need to
wait for a refresh.
When migrating existing boolean flags, add where Value == Bool to their
AnalyticsFeatureFlag extensions and use AnalyticsFeatureFlag<Bool> for explicit
type annotations and standalone initializers. Custom providers must make their
featureEnabled method generic: func featureEnabled<Value>(_ feature: AnalyticsFeatureFlag<Value>) -> Bool.
Providers that support payloads should implement both featureFlagValue
overloads, including the one constrained to Value: Decodable.
Tests can supply payloads using
TestingAnalyticsProvider.setFeatureFlagValue(_:for:).
enum OnboardingVariant: String, AnalyticsExperimentVariant {
case control
case shortFlow = "short_flow"
case videoIntro = "video_intro"
}
extension AnalyticsExperiment where Variant == OnboardingVariant {
static let onboarding = AnalyticsExperiment(
"onboarding_experiment",
variantType: OnboardingVariant.self
)
}// Simple event
phoenix.logEvent(.loginTapped)
// Event with screen context
phoenix.logEvent(.itemViewed, on: .productDetail)
// Event with additional parameters
phoenix.logEvent(
.searchPerformed(query: "swift"),
additionalParameters: [
.success: true,
.itemCategory: "books"
]
)phoenix.logScreenView(.productDetail, parameters: [
.contentId: "product-123"
])// Set user ID
phoenix.setUserId("user-12345")
// Set individual property
phoenix.setUserProperty(.subscriptionTier, to: "premium")
// Set multiple properties
phoenix.setUserProperties([
.subscriptionTier: "premium",
.referralSource: "friend"
])Properties attached to all subsequent events:
// Register global properties
phoenix.registerGlobalProperties([
.experimentGroup: "A",
.subscriptionTier: "free"
])
// Remove a global property
phoenix.unregisterGlobalProperty(.experimentGroup)Track UI element interactions:
// Track button click
phoenix.logElementClick(
name: .submitButton,
type: .button,
on: .checkout
)
// Track with value
phoenix.logElementClick(
name: "quantity_stepper",
type: .number,
value: 5,
on: .productDetail
)Force pending events to be sent immediately:
// Flush before app goes to background
phoenix.flush()Phoenix provides SwiftUI environment integration:
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.phoenix, phoenix)
.environment(\.analyticsScreen, .home)
}
}
}Track clicks with view modifiers:
Button("Add to Cart") {
addToCart()
}
.analyticsClick(
name: "add_to_cart",
type: .button,
screen: .productDetail
)Automatically track screen views:
NavigationStack {
ProductListView()
.analyticsScreen(.productDetail)
}A drop-in Button replacement with built-in analytics. Prefer this over .analyticsClick() which can sometimes override tap behavior on certain views.
// Simple usage
AnalyticsTrackingButton(name: "submit") {
submitForm()
} label: {
Text("Submit")
}
// With custom type and parameters
AnalyticsTrackingButton(
name: "add_to_cart",
type: .button,
parameters: [
.contentId: product.id,
.price: product.price
]
) {
addToCart(product)
} label: {
Label("Add to Cart", systemImage: "cart.badge.plus")
}Track modal presentations with context:
.sheet(isPresented: $showDetail) {
DetailView()
.analyticsPresentationContext(
elementName: .productCell,
elementType: .cell,
screen: .search
)
}if phoenix.featureEnabled(.newCheckoutFlow) {
showNewCheckout()
} else {
showLegacyCheckout()
}switch phoenix.enabledVariant(for: .onboarding) {
case .control:
showStandardOnboarding()
case .shortFlow:
showShortOnboarding()
case .videoIntro:
showVideoOnboarding()
case nil:
showStandardOnboarding() // Default fallback
}Task {
await phoenix.reloadFeatureFlags()
updateUI()
}// Log StoreKit transaction
phoenix.logTransaction(transaction)do {
try riskyOperation()
} catch {
phoenix.logError(
error,
on: .settings,
additionalParameters: [.init("operation"): "data_sync"]
)
}MIT License. See LICENSE for details.
