Event Tracking Patterns for Mobile Analytics
Learn proven event tracking patterns for mobile analytics, including naming conventions, property schemas, and type-safe tracking APIs in Swift.
Event tracking is the lifeblood of mobile analytics. Every tap, scroll, purchase, and error generates data that drives product decisions. But without disciplined tracking patterns, analytics data quickly becomes an unreliable mess of inconsistent event names, missing properties, and duplicated signals. The difference between useful analytics and noise often comes down to the patterns you establish for defining, naming, and firing events.
This article covers the event tracking patterns we have refined while building KlivvrAnalyticsKit, with a focus on type safety, consistency, and maintainability in Swift codebases.
Type-Safe Event Definitions
The most common analytics bug is a typo in an event name or property key. String-based tracking APIs are a liability at scale. Swift's type system gives us the tools to eliminate entire categories of tracking errors at compile time.
// Define events as structured types, not strings
protocol TrackableEvent {
var eventName: String { get }
var properties: [String: Any] { get }
}
// Specific event definitions
struct ScreenViewedEvent: TrackableEvent {
let screenName: String
let screenClass: String
let referrer: String?
var eventName: String { "screen_viewed" }
var properties: [String: Any] {
var props: [String: Any] = [
"screen_name": screenName,
"screen_class": screenClass
]
if let referrer {
props["referrer"] = referrer
}
return props
}
}
struct PurchaseCompletedEvent: TrackableEvent {
let productId: String
let productName: String
let price: Decimal
let currency: String
let quantity: Int
var eventName: String { "purchase_completed" }
var properties: [String: Any] {
[
"product_id": productId,
"product_name": productName,
"price": NSDecimalNumber(decimal: price).doubleValue,
"currency": currency,
"quantity": quantity
]
}
}
// Type-safe tracking API
extension KlivvrAnalytics {
func track(_ event: TrackableEvent) {
track(event.eventName, properties: event.properties)
}
}
// Usage - compile-time safety
KlivvrAnalytics.shared.track(PurchaseCompletedEvent(
productId: "SKU-12345",
productName: "Premium Subscription",
price: 9.99,
currency: "USD",
quantity: 1
))This pattern makes it impossible to send a purchase event without a product ID. The compiler enforces your tracking plan.
Event Naming Conventions
Consistent naming is more important than clever naming. We follow a strict convention: object-action format in snake_case, with a controlled vocabulary.
// Event naming taxonomy
enum EventCategory: String {
case screen = "screen"
case button = "button"
case form = "form"
case purchase = "purchase"
case notification = "notification"
case error = "error"
case session = "session"
case feature = "feature"
}
enum EventAction: String {
case viewed = "viewed"
case tapped = "tapped"
case submitted = "submitted"
case completed = "completed"
case failed = "failed"
case started = "started"
case ended = "ended"
case dismissed = "dismissed"
case received = "received"
case enabled = "enabled"
case disabled = "disabled"
}
// Generate consistent event names from taxonomy
struct CategorizedEvent: TrackableEvent {
let category: EventCategory
let action: EventAction
let additionalProperties: [String: Any]
var eventName: String {
"\(category.rawValue)_\(action.rawValue)"
}
var properties: [String: Any] {
additionalProperties
}
}
// This creates events like:
// "screen_viewed", "button_tapped", "purchase_completed", "form_submitted"A controlled vocabulary prevents the proliferation of near-duplicate event names like "btn_click", "button_clicked", "buttonTap", and "tap_button" that plague many analytics implementations.
Event Enrichment Patterns
Raw events lack context. Enrichers automatically attach metadata that makes events useful for analysis without burdening the tracking call site.
// Device context enricher
struct DeviceEnricher: EventEnricher {
func enrich(_ event: inout AnalyticsEvent) {
event.properties["device_model"] = UIDevice.current.model
event.properties["os_version"] = UIDevice.current.systemVersion
event.properties["app_version"] = Bundle.main.infoDictionary?["CFBundleShortVersionString"]
event.properties["build_number"] = Bundle.main.infoDictionary?["CFBundleVersion"]
event.properties["locale"] = Locale.current.identifier
event.properties["timezone"] = TimeZone.current.identifier
}
}
// Session enricher
final class SessionEnricher: EventEnricher {
private var sessionId: String = UUID().uuidString
private var sessionStartTime: Date = Date()
private var eventIndex: Int = 0
private let sessionTimeout: TimeInterval = 1800 // 30 minutes
func enrich(_ event: inout AnalyticsEvent) {
checkSessionExpiry()
event.properties["session_id"] = sessionId
event.properties["session_duration"] = Date().timeIntervalSince(sessionStartTime)
event.properties["event_index"] = eventIndex
eventIndex += 1
}
private func checkSessionExpiry() {
let timeSinceStart = Date().timeIntervalSince(sessionStartTime)
if timeSinceStart > sessionTimeout {
sessionId = UUID().uuidString
sessionStartTime = Date()
eventIndex = 0
}
}
}
// User properties enricher
final class UserEnricher: EventEnricher {
private var userId: String?
private var userProperties: [String: Any] = [:]
func identify(userId: String, properties: [String: Any] = [:]) {
self.userId = userId
self.userProperties = properties
}
func enrich(_ event: inout AnalyticsEvent) {
if let userId {
event.properties["user_id"] = userId
}
for (key, value) in userProperties {
event.properties["user_\(key)"] = value
}
}
}By stacking enrichers, each event automatically carries device info, session context, and user identity without any extra code at the call site.
Funnel and Flow Tracking
Individual events tell you what happened. Funnels tell you what did not happen. Tracking user flows through multi-step processes requires a pattern that links related events together.
// Funnel tracking with step correlation
final class FunnelTracker {
private let analytics: KlivvrAnalytics
private var activeFunnels: [String: FunnelState] = [:]
struct FunnelState {
let funnelId: String
let funnelName: String
var currentStep: Int
let startTime: Date
}
func startFunnel(name: String) -> String {
let funnelId = UUID().uuidString
activeFunnels[funnelId] = FunnelState(
funnelId: funnelId,
funnelName: name,
currentStep: 0,
startTime: Date()
)
analytics.track("funnel_started", properties: [
"funnel_id": funnelId,
"funnel_name": name
])
return funnelId
}
func trackStep(funnelId: String, stepName: String, properties: [String: Any] = [:]) {
guard var state = activeFunnels[funnelId] else { return }
state.currentStep += 1
activeFunnels[funnelId] = state
var eventProperties: [String: Any] = [
"funnel_id": funnelId,
"funnel_name": state.funnelName,
"step_number": state.currentStep,
"step_name": stepName,
"time_in_funnel": Date().timeIntervalSince(state.startTime)
]
eventProperties.merge(properties) { _, new in new }
analytics.track("funnel_step_completed", properties: eventProperties)
}
func completeFunnel(funnelId: String) {
guard let state = activeFunnels[funnelId] else { return }
analytics.track("funnel_completed", properties: [
"funnel_id": funnelId,
"funnel_name": state.funnelName,
"total_steps": state.currentStep,
"total_duration": Date().timeIntervalSince(state.startTime)
])
activeFunnels.removeValue(forKey: funnelId)
}
func abandonFunnel(funnelId: String, reason: String? = nil) {
guard let state = activeFunnels[funnelId] else { return }
var properties: [String: Any] = [
"funnel_id": funnelId,
"funnel_name": state.funnelName,
"abandoned_at_step": state.currentStep,
"total_duration": Date().timeIntervalSince(state.startTime)
]
if let reason {
properties["abandonment_reason"] = reason
}
analytics.track("funnel_abandoned", properties: properties)
activeFunnels.removeValue(forKey: funnelId)
}
}
// Usage for an onboarding funnel
let funnelId = funnelTracker.startFunnel(name: "onboarding")
funnelTracker.trackStep(funnelId: funnelId, stepName: "welcome_screen")
funnelTracker.trackStep(funnelId: funnelId, stepName: "profile_setup")
funnelTracker.trackStep(funnelId: funnelId, stepName: "preferences")
funnelTracker.completeFunnel(funnelId: funnelId)Automatic Screen Tracking
Manual screen tracking is error-prone because developers forget to add tracking calls. Swizzling or base class approaches can automate this, but they come with trade-offs. A protocol-based opt-in approach strikes the right balance.
// Protocol for automatic screen tracking
protocol AnalyticsScreenTracking: UIViewController {
var analyticsScreenName: String { get }
var analyticsScreenProperties: [String: Any] { get }
}
extension AnalyticsScreenTracking {
var analyticsScreenProperties: [String: Any] { [:] }
}
// Base tracking implementation via viewDidAppear hook
extension UIViewController {
@objc func analytics_viewDidAppear(_ animated: Bool) {
analytics_viewDidAppear(animated) // Calls original (swizzled)
if let trackable = self as? AnalyticsScreenTracking {
var properties: [String: Any] = [
"screen_name": trackable.analyticsScreenName,
"screen_class": String(describing: type(of: self))
]
properties.merge(trackable.analyticsScreenProperties) { _, new in new }
KlivvrAnalytics.shared.track("screen_viewed", properties: properties)
}
}
}
// Opt in to tracking per screen
class ProductDetailViewController: UIViewController, AnalyticsScreenTracking {
let product: Product
var analyticsScreenName: String { "product_detail" }
var analyticsScreenProperties: [String: Any] {
[
"product_id": product.id,
"product_category": product.category
]
}
}Practical Tips
When implementing event tracking patterns, start with a tracking plan document that lists every event, its properties, and its expected values. Share this document across engineering, product, and data teams. Use enums and structs for event definitions so refactoring tools can find every usage. Run automated tests that verify your critical events fire with the correct properties. Avoid tracking everything -- be intentional about what you measure, because excessive tracking creates noise that obscures signal.
Conclusion
Strong event tracking patterns transform analytics from a chore into a reliable data foundation. Type-safe event definitions catch errors at compile time. Consistent naming conventions make data queryable. Enrichers add context without cluttering call sites. Funnel tracking reveals where users drop off. These patterns, applied consistently with KlivvrAnalyticsKit, ensure that the data flowing from your app to your dashboard is accurate, complete, and actionable.
Related Articles
Debugging Analytics: Ensuring Accurate Event Tracking
Master techniques for debugging analytics implementations in iOS apps, from real-time event inspection to automated validation and production monitoring.
Ensuring Data Quality in Mobile Analytics
Establish data quality practices for mobile analytics, including validation, monitoring, testing, and governance to maintain trustworthy analytics data.
Turning Product Analytics into Actionable Insights
Learn how to transform raw analytics data into product decisions by defining KPIs, building dashboards, and establishing analysis workflows for mobile apps.