Skip to content

AnalyticsSystem 2.0.0 — dependency-free core, Swift 6 concurrency, real test coverage - #8

Merged
AndrewKochulab merged 1 commit into
masterfrom
feature/v2-modernization
Aug 18, 2026
Merged

AnalyticsSystem 2.0.0 — dependency-free core, Swift 6 concurrency, real test coverage#8
AndrewKochulab merged 1 commit into
masterfrom
feature/v2-modernization

Conversation

@AndrewKochulab

Copy link
Copy Markdown
Owner

Why

The package was last touched in November 2020 and no longer builds. Firebase and Facebook were pinned to .branch("master"), the deployment target was iOS 10 (below what current Xcode accepts), and the adapters call SDK APIs that were removed years ago — Facebook moved AppEvents/Settings off the type level in SDK v12.

CI had been green that entire time while proving nothing: it ran swift build on macOS, but every provider is #if os(iOS)-gated, so not one line of adapter code was ever compiled.

The headline change

A default install now resolves zero third-party dependencies. Previously every consumer pulled Firebase's whole transitive tree — gRPC, BoringSSL, abseil, leveldb, nanopb, Promises, GTMSessionFetcher — just to use the console tracker. Providers are now opt-in package traits:

.package(url: "...", from: "2.0.0", traits: ["Firebase", "Mixpanel"])

Verified: swift package resolve with default traits produces no Package.resolved at all.

Crashers fixed

event as! Event Commented // safe operation; it was the framework's only type-recovery mechanism and trapped on any factory/event mismatch. Events now capture their concrete type in a closure at the generic call site — nothing on the event path casts.
Two fatalError inits MixpanelTracker/BugsnagTracker declared required init(eventsFactory:) { fatalError() } to satisfy an inherited requirement, making addTracker(_:with:) a guaranteed crash. The library no longer constructs trackers, so the requirement is gone.
Unsynchronised state The tracker dictionary and enabled flag were mutated from any thread. Now behind an actor.

Correctness fixed

  • Mixpanel logOut never called reset() — the distinct ID survived log out, so the next user on a device was silently correlated with the previous one. Worth knowing this will show as a discontinuity in Mixpanel data at upgrade.
  • Bugsnag could never receive an event (isAvailable hardcoded false).
  • Firebase never reported crashes (appDidCrashLastLaunch() returned false unconditionally, though Crashlytics answers it).
  • Clearing the anonymous ID did not clear it@UserDefault stored a null instead of removing the key.
  • UIApplication.LaunchOptionsKey leaked into the cross-platform protocol, its typealias guarded on canImport(UIKit) while the import was guarded on os(iOS).

Design

Two root causes drove nearly every defect: type erasure happened too early, and behaviour was carried by class inheritance plus mutable stored state — the exact thing that cannot be made Sendable. So v2 erases late and composes instead of inheriting. Mapping and filtering bind at registration rather than being baked into a FactoryAnalyticsTracker<F> subclass.

track stays synchronous and callable from any isolation domain — making the facade an actor would have put a suspension point in every UI call site. Lifecycle calls and events share one serial queue, which is what guarantees logIn reaches providers before a track issued right after it.

Swift 6 language mode throughout, with no @unchecked Sendable anywhere except one documented NSLock box (Mutex needs iOS 18, above the iOS 15 floor).

Verification

  • 57 tests / 11 suites (Swift Testing). v1 had a single test whose only assertion was commented out. Each v1 defect has a named regression test.
  • All four adapters compiled against their real SDKs (Firebase 12.17, Facebook 18.1, Mixpanel 6.5, Bugsnag 6.37) via a new IntegrationTests/ProviderBuild package built for iOS Simulator — this is the gate that closes the CI blind spot.
  • Core builds for iOS, macOS, tvOS, watchOS, visionOS.
  • swift build -Xswiftc -strict-concurrency=complete → 0 diagnostics.
  • swiftlint --strict → 0 violations. pod lib lint passes.
  • Every README example is compiled by the test suite — all of v1's failed to compile.

Compatibility

Breaking, hence 2.0.0. 1.0.0 is untouched and stays installable; consumers below iOS 15 or on Xcode < 16.3 simply stay there. MIGRATION.md maps every symbol.

The CocoaPods spec exposes all four providers again — v1 had quietly deleted the Firebase and Mixpanel subspecs, so CocoaPods and SPM shipped different sets. Traits are emulated there via SWIFT_ACTIVE_COMPILATION_CONDITIONS.

🤖 Generated with Claude Code

…e (2.0.0)

The package had not been touched since November 2020 and no longer built:
Firebase and Facebook were pinned to `.branch("master")`, the deployment
target was iOS 10, and the adapters called SDK APIs removed years ago.

BREAKING CHANGE: 2.0.0 redesigns the public API. 1.0.0 is untouched and
remains installable; see MIGRATION.md for a symbol-by-symbol map.

Fixes, each with a regression test:

* Force cast in the public API. `FactoryAnalyticsTracker` recovered an
  event's concrete type with `event as! Event`, commented "safe operation".
  Events now carry their type in a closure captured at the generic call
  site, so nothing on the event path casts at all.
* Two `fatalError` initializers reachable from public API, which made
  `addTracker(_:with:)` a guaranteed crash for Mixpanel and Bugsnag. The
  library no longer constructs trackers, so the requirement is gone.
* Mixpanel `logOut` never called `reset()`, so the distinct ID survived
  log out and the next user was silently correlated with the previous one.
* The registry keyed on `String(describing:)` — unstable, and unable to
  tell two instances of one provider apart.
* No synchronisation on the tracker dictionary or the enabled flag.
* `UIApplication.LaunchOptionsKey` leaked into the cross-platform tracker
  protocol, guarded inconsistently against its own import.
* `@UserDefault` stored a null instead of removing a key, so clearing the
  anonymous ID did not clear it; it also called deprecated `synchronize()`.
* Bugsnag could never receive an event; Firebase never reported crashes.
* CI ran `swift build` on macOS while every provider was `os(iOS)`-gated,
  so no adapter body was ever compiled — green for years, proving nothing.

Structural changes:

* Package traits gate each provider. A default install now resolves ZERO
  third-party packages; previously every consumer pulled Firebase's entire
  transitive tree just to use the console tracker.
* Swift 6 language mode throughout, with no `@unchecked Sendable` outside
  one documented mutex box. `[String: Any]` becomes `AnalyticsValue`.
* Mapping and filtering move from class inheritance to composable values
  bound at registration.
* Dispatch moves off `OperationQueue.main` onto one serial queue, which
  also gives ordering between lifecycle calls and events.
* Platforms: iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1.
* 57 tests across 11 suites (v1 had one test with its assertion commented
  out), plus an integration package that compiles all four adapters
  against their real SDKs for iOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AndrewKochulab
AndrewKochulab merged commit f8a655b into master Aug 18, 2026
9 checks passed
@AndrewKochulab
AndrewKochulab deleted the feature/v2-modernization branch August 18, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant