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
etAnalyticsUserActionTag)
- le tracking tierce, avec pour le moment une seule solution avec les tags d’écrans et d’évènements (
ThirdPartyTrackerScreen
etThirdPartyTrackerEvent
)
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 :
envoyer :
|
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
…