Publié par

Il y a 9 mois -

Temps de lecture 10 minutes

Tracking analytics ? (Presque) Un plaisir !

Le tracking… dans tout projet, nous savons que tôt ou tard (bien souvent trop tard d’ailleurs !) des stories avec tout plein de tags vont débarquer au cours d’un sprint 😱. Et quand elles arrivent, en tant que bons crafts(wo)men que nous sommes, nous appréhendons que notre joli code tout beau tout propre soit pollué par l’implémentation des ces tags d’autant plus que bien souvent, une seule solution de tracking ne suffit pas ; ça serait trop simple ! 🙃

Cet article a pour objectif de décrire un exemple d’architecture pour le tracking afin de simplifier / améliorer :

  • la maintenance de futurs évènements et écrans à tracker
  • l’ajout d’un nouvel SDK
  • la suppression ou la migration d’un SDK vers un autre
  • et bien plus encore… (quel teaser ! 🤤)

Bref : un vrai plaisir ! 🥰

L’architecture : abstraire toute la logique de tracking pour ensuite y connecter chaque solution

Le concept est simple : créer son propre tracking interne à l’app, décorrélé de toute solution tierce. Tous les tags de l’application (tap sur un bouton, affichage d’un écran, changement d’un état, évènement…) seront envoyés à un « collecteur » de tags que l’on appellera AnalyticsHub.

Ensuite, chaque solution de tracking souhaitée (AT Internet, Firebase, Batch…) fera l’objet d’une classe qui sera chargée de transformer les tags provenant du hub en tag spécifique à la solution, puis de les envoyer au SDK respectif.

Pour la bonne compréhension de cet article, gardez bien en tête deux notions importantes :

  • Le tracking interne : c’est votre propre tracking que vous alimenterez comme bon vous semble, qui n’est connecté à aucune solution de tracking du marché.
  • Le tracking tierce : il s’agit de solutions tierces, comme Firebase ou Batch par exemple.

A présent, procédons étape par étape pour mettre en place cette architecture (exemple décrit en Swift mais très facilement adaptable en Kotlin par exemple).

Step 1 : lister les types de tag

Au cours de nombreux projets, j’ai pu identifier 4 catégories de tags qui semblent répondre à toutes les typologies d’application :

  • tags d’écran (affichage d’un écran)
  • tags d’action utilisateur (principalement tous les taps, mais pourquoi pas un scroll, swipe…)
  • tags d’évènement indépendant de l’utilisateur (une erreur, une lecture audio ou vidéo qui se termine…)
  • tags d’état ou propriété utilisateur (nombre de favoris, notification on/off…)

Créons alors 4 enum correspondant à chaque type ci-dessus :

  • AnalyticsScreenTag
  • AnalyticsUserActionTag
  • AnalyticsEventTag
  • AnalyticsStateTag

Et pour chacun, nous allons lister les tags dont on a besoin. Partons sur une application de lecture de podcasts avec quatre écrans :

  • Liste de podcasts
  • Lecteur de podcast
  • Mes favoris
  • Mes téléchargements

Ce qui nous donnerait par exemple :

enum AnalyticsScreenTag {
	case podcasts
	case podcastPlayer
	case favorites
	case downloads
}

enum AnalyticsUserActionTag {
	case playPodcast(Podcast)
	case pausePodcast(Podcast)
	case downloadPodcast(Podcast)
	case deletePodcast(Podcast)
	case addPodcastToFavorite(Podcast)
	case removePodcastFromFavorite(Podcast)
}

enum AnalyticsEventTag {
	case playerDidLoad(Podcast)
	case playerDidPlay(Podcast)
	case playerDidStop(Podcast)
	case playerDidFail(Podcast, Error)
	case notEnoughStorageSpaceForDownload(Podcast)
}

enum AnalyticsStateTag {
	case autoPlay(Bool)
	case favorites([Podcast])
	case remoteNotificationAuthorized(Bool)
}

Step 2 : créer l’AnalyticsHub qui recevra tous les tags

L’AnalyticsHub recevra tous les tags depuis les ViewModel (par exemple si vous avez eu l’excellente idée d’appliquer une architecture MVVM dans votre application 👌🏼). Ensuite, comme évoqué plus haut, il dispatchera tous ces tags aux différentes stratégies dédiées aux solutions tierces.

final class AnalyticsHub: AnalyticsHubProtocol {

	func send(event: AnalyticsEventTag) {
	}

	func send(screen: AnalyticsScreenTag) {
	}

	func send(state: AnalyticsStateTag) {
	}

	func send(userAction: AnalyticsUserActionTag) {
	}
}

Il nous faut maintenant écrire nos AnalyticsStrategy pour implémenter ces fonctions.

Step 3 : définir nos AnalyticsStrategy

Nous aurons autant de protocoles de stratégie que de types de tag :

protocol AnalyticsEventStrategy {
	func send(event: AnalyticsEventTag)
}

protocol AnalyticsScreenStrategy {
	func send(screen: AnalyticsScreenTag)
}

protocol AnalyticsStateStrategy {
	func send(state: AnalyticsStateTag)
}

protocol AnalyticsUserActionStrategy {
	func send(userAction: AnalyticsUserActionTag)
}

 

En effet, chaque solution tierce implémentera la stratégie dont elle aura besoin… tous n’auront pas nécessairement à traiter tous les types de tag.

A présent, créons une stratégie pour notre première solution de tracking (qui pourrait être Firebase, Batch, etc. et que nous nommerons ici ThirdPartyTracker) qui implémentera les 4 protocoles :

final class ThirdPartyTrackerAnalyticsStrategy: AnalyticsScreenStrategy, AnalyticsUserActionStrategy, AnalyticsEventStrategy, AnalyticsStateStrategy {

	// MARK: - AnalyticsScreenStrategy conformance

	func send(screen: AnalyticsScreenTag) {
	}

	// MARK: - AnalyticsUserActionStrategy conformance

	func send(userAction: AnalyticsUserActionTag) {
	}

	// MARK: - AnalyticsEventStrategy conformance

	func send(event: AnalyticsEventTag) {
	}

	// MARK: - AnalyticsStateStrategy conformance

	func send(state: AnalyticsStateTag) {
	}
}

 

Il nous faut également créer la classe qui interagira directement avec le SDK de la solution. Notre stratégie est uniquement chargée d’intercepter tous les tags envoyés par l’app, les traiter (si besoin) et les adapter pour la solution de tracking cible. Notre découpage par catégorie de tag est très certainement différent de celui de la solution de tracking, imaginons que le SDK dispose seulement de 2 fonctions :

func trackScreen(name: String)
func trackEvent(name: String, data: [String: Any]?)

 

Créons alors ce qu’on appellera un Tagger, mais également les tags souhaités par la solution, sous forme d’enum, comme nous avons pu le faire plus haut pour le tracking interne. Supposons que pour cette solution de tracking, nous souhaitons simplement tracker les écrans podcasts (ni downloads et ni favoris donc) et la lecture / pause d’un podcast uniquement :

enum ThirdPartyTrackerScreen {
	case podcasts
	case podcastPlayer

	var name: String {
		switch self {
		case .podcasts: return "show_podcasts"
		case .podcastPlayer: return "show_podcast_player"
		}
	}
}

 

enum ThirdPartyTrackerEvent {
	case play(title: String, duration: Int)
	case pause

	var name: String {
		switch self {
		case .play: return "play_podcast"
		case .pause: return "pause_podcast"
		}
	}

	var data: [String: Any]? {
		switch self {
		case let .play(title, duration): return ["podcast_title": title, "podcast_duration": duration]
		case .pause: return nil
		}
	}
}

 

import ThirdPartyTracker

final class ThirdPartyTrackerTagger {

	private let tracker = ThirdPartyTracker()

	func send(_ screen: ThirdPartyTrackerScreen) {
		tracker.trackScreen(name: screen.name)
	}

	func send(_ event: ThirdPartyTrackerEvent) {
		tracker.trackEvent(name: event.name, data: event.data)
	}
}

 

A présent, pour schématiser nous avons deux trackings en parallèle :

  • le tracking interne, avec notamment nos tags d’écrans et d’actions utilisateurs (AnalyticsScreenTag et AnalyticsUserActionTag)
  • le tracking tierce, avec pour le moment une seule solution avec les tags d’écrans et d’évènements (ThirdPartyTrackerScreen et ThirdPartyTrackerEvent)

Vous l’aurez compris, il nous faut maintenant réunir ces deux mondes ! 👯‍♀️
Pour cela, convertissons les tags internes de l’app en tag de la solution grâce aux extensions :

extension AnalyticsUserActionTag {

	func toThirdPartyTrackerEvent() -> ThirdPartyTrackerEvent? {

		switch self {
		case .playPodcast(let podcast): return .play(podcast.title, podcast.duration)
		case .pausePodcast: return .pause
		default: return nil
		}
	}
}

extension AnalyticsScreenTag {

	func toThirdPartyTrackerScreen() -> ThirdPartyTrackerScreen? {

		switch self {
		case .podcasts: return .podcasts
		case .podcastPlayer: return .podcastPlayer
		default: return nil
		}
	}
}

 

Vous constatez que la fonction peut retourner un optional : en effet, tous les tags ne sont pas nécessairement traités par la solution (ceux à ignorer passeront dans le default).
Faites de même avec les autres types de tags si nécessaire (AnalyticsEventTag et AnalyticsStateTag).

Revenons à notre stratégie. Nous l’avons initié mais il nous reste encore à écrire l’implémentation des différentes fonctions et à y instancier le Tagger décrit plus haut :

final class ThirdPartyTrackerAnalyticsStrategy: AnalyticsScreenStrategy, AnalyticsUserActionStrategy, AnalyticsEventStrategy, AnalyticsStateStrategy {

	private let tagger = ThirdPartyTrackerTagger()

	// MARK: - AnalyticsScreenStrategy conformance

	func send(screen: AnalyticsScreenTag) {
		guard let screen = screen.toThirdPartyTrackerScreen() else { return }
		tagger.send(screen)
	}

	// MARK: - AnalyticsUserActionStrategy conformance

	func send(userAction: AnalyticsUserActionTag) {
		guard let event = userAction.toThirdPartyTrackerEvent() else { return }
		tagger.send(event)
	}

	// MARK: - AnalyticsEventStrategy conformance

	func send(event: AnalyticsEventTag) {
		...
	}

	// MARK: - AnalyticsStateStrategy conformance

	func send(state: AnalyticsStateTag) {
		...
	}
}

 

Enfin, n’oublions pas de revenir également sur notre AnalyticsHub :

final class AnalyticsHub: AnalyticsHubProtocol {
    
    private let strategies: [Any]
    
    // MARK: - Init
    
    init(_ strategies: [Any]) {
        self.strategies = strategies
    }
    
    // MARK: - Event
    
    func send(event: AnalyticsEventTag) {
        strategies
            .compactMap { $0 as? AnalyticsEventStrategy }
            .forEach { $0?.send(event: event) }
    }
    
    // MARK: - Screen
    
    func send(screen: AnalyticsScreenTag) {
        strategies
            .compactMap { $0 as? AnalyticsScreenStrategy }
            .forEach { $0?.send(screen: screen) }
    }
    
    // MARK: - State
    
    func send(state: AnalyticsStateTag) {
        strategies
            .compactMap { $0 as? AnalyticsStateStrategy }
            .forEach { $0?.send(state: state) }
    }
    
    // MARK: - User action
    
    func send(userAction: AnalyticsUserActionTag) {
        strategies
            .compactMap { $0 as? AnalyticsUserActionStrategy }
            .forEach { $0?.send(userAction: userAction) }
    }
}

Et voilà, nous en avons terminé avec le tracking. Toutes ces étapes peuvent paraitre laborieuses à mettre en place, mais vous verrez qu’à l’avenir il sera plus facile de faire évoluer le tracking de votre app.
Pour récapituler, voici l’architecture sous forme de schéma :

 

Et dans Xcode, voici à quoi pourrait ressembler l’arborescence de votre dossier Analytics :

One more thing : avoir toujours connaissance de l’écran en cours

Une nouvelle story vient d’entrer dans le sprint :

EN TANT QUE consultant analytics
JE VEUX connaitre depuis quel écran l’utilisateur a lancé la lecture d’un podcast
AFIN DE mieux analyser les écoutes
Au lieu d’envoyer un simple tag :

play_podcast avec comme payload title: String et duration: Int

envoyer :

play_podcast_from_podcasts et play_podcast_from_favorites avec le même payload

 

Et là vous vous dites « mince, je n’avais pas prévu ce cas… 🤔 ». Pas de panique, notre architecture enregistre déjà tous les affichages d’écran (AnalyticsScreenTag), il nous suffit alors de conserver cet historique et d’en faire bon usage 😎

Pour commencer, notre enum ThirdPartyTrackerEvent va évoluer comme suit pour répondre aux besoins de la story :

enum ThirdPartyTrackerEvent {
	case playFromPodcasts(title: String, duration: Int)
	case playFromFavorites(title: String, duration: Int)
	case pause

	var name: String {
		switch self {
		case .playFromPodcasts: return "play_podcast_from_podcasts"
		case .playFromFavorites: return "play_podcast_from_favorites"
		case .pause: return "pause_podcast"
		}
	}

	var data: [String: Any]? {
		switch self {
		case let .playFromPodcasts(title, duration),
		         .playFromFavorites(title, duration): return ["podcast_title": title, "podcast_duration": duration]
		case .pause: return nil
		}
	}
}

 

Ensuite, conservons un historique des écrans dans notre hub… (à voir selon vos besoins, peut-être que seul le dernier écran suffit… ici nous limiterons la taille du tableau à 5 écrans par exemple)

final class AnalyticsHub {

	private let strategies: [Any]
	private var screenHistory = [AnalyticsScreenTag]()
	private var currentScreen: AnalyticsScreenTag? {
		return screenHistory.last
	}

	// MARK: - Screen

	func send(screen: AnalyticsScreenTag) {
		screenHistory.append(screen)
		screenHistory = Array(screenHistory.suffix(5))
	
		strategies
			.compactMap { $0 as? AnalyticsScreenStrategy }
			.forEach { $0?.send(screen: screen) }
	}
}

 

…et envoyons l’écran en cours à chaque fois que l’on appelle un AnalyticsUserActionTag (vous pourriez en faire de même pour les AnalyticsEventTag et AnalyticsStateTag, mais c’est rarement nécessaire car ce ne sont pas des tags liés au contexte écran) :

// MARK: - User action

func send(userAction: AnalyticsUserActionTag) {
	strategies
		.compactMap { $0 as? AnalyticsUserActionStrategy }
		.forEach { $0?.send(userAction: userAction, inCurrentScreen: currentScreen) }
}

 

Quelques modifications s’imposent dans les stratégies et transformations de tags :

protocol AnalyticsUserActionStrategy {
	func send(userAction: AnalyticsUserActionTag, inCurrentScreen: AnalyticsScreenTag?)
}

 

final class ThirdPartyTrackerAnalyticsStrategy: AnalyticsScreenStrategy, AnalyticsUserActionStrategy, AnalyticsEventStrategy, AnalyticsStateStrategy {

	...

	// MARK: - AnalyticsUserActionStrategy conformance

	func send(userAction: AnalyticsUserActionTag, inCurrentScreen currentScreen: AnalyticsScreenTag?) {
		guard let event = userAction.toThirdPartyTrackerEvent(inCurrentScreen: inCurrentScreen) else { return }
		tagger.send(event)
	}
}

 

extension AnalyticsUserActionTag {

	func toThirdPartyTrackerEvent(inCurrentScreen currentScreen: AnalyticsScreenTag?) -> ThirdPartyTrackerEvent? {

		switch (self, currentScreen) {
		case (.playPodcast(let podcast), .some(.podcasts)): return .playFromPodcasts(podcast.title, podcast.duration)
		case (.playPodcast(let podcast), .some(.favorites)): return .playFromFavorites(podcast.title, podcast.duration)
		case (.pausePodcast, _): return .pause
		default: return nil
		}
	}
}

 

Et voilà, avec cette architecture vous devriez couvrir un très large pourcentage de cas de tracking que vous pourriez rencontrer tout au long d’un projet. Rien ne vous empêche de la mettre en place dès le début des développements, à minima pour le tracking des écrans. Vous pourriez aussi anticiper tous les autres tags, mais c’est un risque d’avoir du code mort dans votre projet. A vous de voir !

Derniers points : cette architecture permet également de facilement gérer tout ce qui est RGPD 🕺🏻 (vous pouvez connecter / déconnecter des stratégies en fonction des choix d’opt-in de vos utilisateurs) mais aussi d’y connecter des stratégies autres que pour le tracking analytics, par exemple un logs ou crashs reporter comme Firebase Crashlytics ou SwiftyBeaver🤩

 

Publié par

Publié par Florent Capon

Je suis consultant iOS et je développe en Objective-C / Swift depuis 2012. Je suis dans le monde du Web et passionné depuis le début des années 2000, où j'ai notamment pu toucher à de nombreuses technos back & front comme le HTML, PHP, MySQL, ActionScript. Aujourd'hui, ma passion est clairement tournée vers le mobile et notamment iOS. Fort de mes 6 années passées à développer des sites en Flash, j'aime apporter un soin particulier à l'animation et à la réalisation des applications sur lesquelles je travaille.

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.