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. trackis synchronous. Call it from a view model, a background task, anywhere. Noawait.- Ordering is guaranteed. A
logInfollowed by atrackreaches every provider in that order. - Nothing fails silently. Every event the system buffers, drops, rejects or rewrites is reported to a diagnostics handler.
| 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 | — |
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.
pod 'AnalyticsSystem' # Core only
pod 'AnalyticsSystem/Firebase' # + Firebase
pod 'AnalyticsSystem/Facebook'
pod 'AnalyticsSystem/Mixpanel'
pod 'AnalyticsSystem/Bugsnag'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.
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()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.
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.
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)")
}
)
)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)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()Filters are values, and they compose:
try await analytics.register(
FacebookTracker(),
filter: .categories(.authentication) || .only(PurchaseEvent.self)
)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.
await analytics.logIn(
user: AnalyticsUser(id: "user-1", firstName: "Ada", email: "ada@example.com")
)
await analytics.logOut() // resets every provider and rotates the anonymous IDif await analytics.didCrashOnLastLaunch() {
// Firebase and Bugsnag both answer this.
}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
}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.
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 calledFull API reference: andrewkochulab.github.io/AnalyticsSystem
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.
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.
MIT. See LICENSE.