TCA and Analytics

I've been thinking about how I would go about replacing the analytics in my app using a "Swifty" and "PointFree" way of approaching the problem.

At the moment the analytics in the app is very ad-hoc and whenever it is done it needs an AnalyticsProvider passing in and then we manually log the event etc... to the provider.

The provider then wraps multiple third party analytics SDKs. (Don't ask why multiple... :sweat_smile:)

Anyway... going for the basic one of just Firebase...

I created a higher order Reducer like...

struct Event {
	let name: String
	let parameters: [String: Any]?

	static func screenView(screenName: String, screenClass: String = "") -> Self {
		.init(name: AnalyticsEventScreenView, parameters: [
			AnalyticsParameterScreenName: screenName,
			AnalyticsParameterScreenClass: screenClass
		])
	}
}

protocol AnalyticsAction {
	var event: Event { get }
}

extension AnalyticsAction {
	var event: Event {
		.init(name: "\(self)", parameters: nil)
	}
}

extension Reducer where Action: AnalyticsAction {
	func firebaseAnalytics() -> Reducer {
		return .init { state, action, environment in
			let effects = self.run(&state, action, environment)

			return .merge(
				.fireAndForget {
					Analytics.logEvent(action.event.name, parameters: action.event.parameters)
				},
				effects
			)
		}
	}
}

So I can essentially turn EVERY action in the app into an AnalyticsAction. (Hmm... possibly I should make this optional... but that's a question for another time).

So now I can create this like...

let appReducer = ...
    .debug()
    .firebaseAnalytics()

And my actions can conform to this by having something like...

enum NumberAction: Equatable, AnalyticsAction {
	case dismissAlertTapped

	case onAppear
	case incrementTapped
	case decrementTapped
	case getRandomNumberTapped
	case randomNumberReceived(Result<Int?, Never>)
	case getFactTapped
	case factReceived(Result<NumberFact?, Never>)

	var event: Event {
		switch self {
		case let .randomNumberReceived(.success(number?)):
			return .init(name: "randomNumberReceived", parameters: ["number": number])
		case let .factReceived(.success(fact?)):
			return .init(
				name: "factReceived",
				parameters: [
					"number": fact.number,
					"fact": fact.text
				]
			)
		case .onAppear:
			return .screenView(screenName: "Number App View", screenClass: "ContentView")
		default:
			return .init(name: "\(self)", parameters: nil)
		}
	}
}

This is working so far. But I was coming here to run this idea past more people to get your input onto it? At the moment it's a fairly basic and naive implementation. Is this something worth exploring further? Or is there a nicer way of doing this?

Also... I noticed that the debug reducer uses a toLocalState and toLocalAction to transform the incoming data. But I'm not entirely sure what that's used for an if that's something I might need also?

Thanks :smiley:

4 Likes

I experimented with a similar idea couple months back, few comments:

  • afaik you don't need the debug reducer for this to work
  • a Sourcery template could help with generating AnalyticsAction boilerplate
  • alternatively the on(Appear/Disappear) screen tracking might be modeled better as an extensions on View because they're slightly different from actions (DataDog example)
  • from my observation some onDisappear actions are not always received when using optional state reducers, which can result in inconsistent analytic data

In general I think this could be a neat addition to the TCA library ecosystem.

1 Like

Thanks! Great to hear that you've been able to do something similar.

The debug bit was just for illustration to show how it's a similar concept. :smiley:

I'm definitely going to explore this a little and see where I can get with it.

1 Like