Skip to content

Repository files navigation

AnalyticsSystem

CI Swift 6.1 Platforms License Documentation

Fan analytics events out to any number of providers behind one small API.

  • The core has zero third-party dependencies. Providers are opt-in package traits, so a default install resolves nothing — not Firebase, not its transitive tree.
  • Built for Swift 6 strict concurrency. No @unchecked Sendable, no locks in your code, no main-thread work.
  • track is synchronous. Call it from a view model, a background task, anywhere. No await.
  • Ordering is guaranteed. A logIn followed by a track reaches every provider in that order.
  • Nothing fails silently. Every event the system buffers, drops, rejects or rewrites is reported to a diagnostics handler.

Providers

Provider Trait Platforms Crash reporting
Firebase Analytics + Crashlytics Firebase iOS, macOS, tvOS
Facebook App Events Facebook iOS
Mixpanel Mixpanel all
Bugsnag Bugsnag all
Console / os.Logger (built in) all

Installation

Swift Package Manager

Core only — resolves no third-party packages at all:

.package(url: "https://github.com/AndrewKochulab/AnalyticsSystem.git", from: "2.0.0")

Opt in to the providers you actually ship. Anything you leave out is never even cloned:

.package(
    url: "https://github.com/AndrewKochulab/AnalyticsSystem.git",
    from: "2.0.0",
    traits: ["Firebase", "Mixpanel"]
)

Requires Swift 6.1 / Xcode 16.3 or newer on the consuming side — package traits are what make the zero-dependency core possible. If you are on an older toolchain, stay on 1.0.0.

CocoaPods

pod 'AnalyticsSystem'                # Core only
pod 'AnalyticsSystem/Firebase'       # + Firebase
pod 'AnalyticsSystem/Facebook'
pod 'AnalyticsSystem/Mixpanel'
pod 'AnalyticsSystem/Bugsnag'

Usage

1. Describe your events

An event is a plain Sendable value that knows its own name and attributes.

import AnalyticsSystem

enum RegistrationMethod: String, AnalyticsValueConvertible {
    case email = "Email"
    case facebook = "Facebook"
}

struct SignUpEvent: AnalyticsEvent {
    static let category: AnalyticsEventCategory = .authentication

    let userID: String
    let method: RegistrationMethod

    var name: AnalyticsEventName { "sign_up" }
    var payload: AnalyticsPayload {
        ["user_id": .string(userID), "method": method.analyticsValue]
    }
}

AnalyticsPayload is a typed bag of AnalyticsValue, not [String: Any] — which is what lets events cross concurrency domains, and what turns "the SDK didn't recognise that value" from silent data loss into an explicit, tested conversion.

2. Register providers

let analytics = AnalyticsSystem()

try await analytics.register(ConsoleTracker())
try await analytics.register(MixpanelTracker(apiToken: "your_token"))
try await analytics.register(BugsnagTracker(apiKey: "your_key"))

await analytics.start()

3. Track

analytics.track(SignUpEvent(userID: "user-1", method: .email))

That's it — synchronous, non-throwing, callable from any isolation domain.

Events tracked before start() are held and replayed once it completes, so launch-time events are neither lost nor handed to an SDK that has not been configured yet. See AnalyticsStartupBuffer.

Attributes on every event

await analytics.setGlobalProperties([
    "app_version": "2.1.0",
    "locale": "en_US",
])

Event attributes win on key conflict, so an event can always override a global.

Seeing what you lose

Analytics failures are invisible by default — a dropped event looks exactly like one that was never sent. A diagnostics handler makes them observable:

let analytics = AnalyticsSystem(
    configuration: .init(
        diagnostics: { diagnostic in
            logger.warning("analytics: \(diagnostic)")
        }
    )
)

Enforcing a provider's limits

Firebase silently discards events that break its rules — names over 40 characters, more than 25 parameters, reserved prefixes. Running those rules locally turns a vanished event into a diagnostic:

try await analytics.register(FirebaseTracker(), validator: .firebase)

Flushing before the app goes away

flush() drains this library's queue; flushProviders() asks each vendor SDK to send what it has batched — that's the one you want when backgrounding.

await analytics.flushProviders()

Sending only some events to a provider

Filters are values, and they compose:

try await analytics.register(
    FacebookTracker(),
    filter: .categories(.authentication) || .only(PurchaseEvent.self)
)

Rendering an event differently for one provider

Override per event type by composing mappers. No subclassing:

let common = AnalyticsEventMapper()

let facebookMapper = AnalyticsEventMapper()
    .mapping(for: SignUpEvent.self) { event in
        AnalyticsRecord(
            name: "fb_mobile_complete_registration",
            attributes: ["fb_registration_method": event.method]
        )
    }
    .overriding(common)

try await analytics.register(FacebookTracker(), mapper: facebookMapper)

Returning nil from a mapping — or using .ignoring(SomeEvent.self) — drops that event for that provider only.

Identity

await analytics.logIn(
    user: AnalyticsUser(id: "user-1", firstName: "Ada", email: "ada@example.com")
)

await analytics.logOut()   // resets every provider and rotates the anonymous ID

Crash reporting

if await analytics.didCrashOnLastLaunch() {
    // Firebase and Bugsnag both answer this.
}

Facebook and UIKit launch options

The core never touches UIKit. The one provider that needs launch options takes them directly from your app delegate:

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    facebookTracker.handleLaunch(options: options)
    Task { await analytics.start() }
    return true
}

Writing your own provider

Implement only what your destination supports — every requirement has a default no-op:

struct MyTracker: AnalyticsTracker {
    let id: AnalyticsTrackerID = "my-tracker"

    func record(_ record: AnalyticsRecord) async {
        // send record.name and record.payload
    }
}

Add CrashReportingTracker if it also observes crashes.

Testing

Inject an in-memory store and a deterministic ID generator, and use flush() as an exact barrier instead of sleeping:

let analytics = AnalyticsSystem(
    configuration: .init(
        store: InMemoryAnalyticsStore(),
        idGenerator: { AnalyticsID(rawValue: "fixed") }
    )
)

analytics.track(SignUpEvent(userID: "1", method: .email))
await analytics.flush()   // returns only once every provider has been called

Documentation

Full API reference: andrewkochulab.github.io/AnalyticsSystem

Migrating from 1.0.0

2.0.0 is a deliberate breaking release — see MIGRATION.md for a symbol-by-symbol map. 1.0.0 is untouched and remains installable.

Contributing

Bug reports and pull requests are welcome — see CONTRIBUTING.md. swift test should be green and swiftlint lint --strict clean before you open one.

⭐️ If you find this useful, star the repo.

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

9 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages