Le contexte
Keepio est une app iOS de gestion de projets/notes/tâches que je développe en solo depuis plus d’un an. Jusqu’ici, je me concentrais uniquement sur la partie développement/workflow pour offrir aux utilisateurs la meilleure expérience. Et ces derniers temps, l’idée germait dans ma tête : Il faut que je comprenne comment mes utilisateurs utilisent l’app, et surtout si j’ai des crashs !
J’ai donc commencé à chercher des outils qui pouvait me permettre d’atteindre mon but sur l’application. J’ai demandé l’avis de devs, et en cherchant un peu lus sur internet, la solution me paraissait clair : pour protéger les données de mes utilisateurs du mieux que je peux, Firebase n'était pas la solution. Alors je me suis dirigée sur le combo Sentry & TelemetryDeck.
Le choix des outils
Besoin → Outil → Pourquoi
Crashs & erreurs → Sentry → Le standard du marché, dashboards riches, stack traces symboliquées, breadcrumbs
Analytics produit → TelemetryDeck → Privacy-first (k-anonymity côté serveur), pas de cookies, pas d'IDFA, conforme RGPD sans consentement explicite
L'idée clé : Sentry est opt-out (on doit tracker les crashs même sans consentement, c'est de la sécurité), TelemetryDeck est opt-in (analytics produit = besoin de l'accord explicite de l'utilisateur).
Pourquoi cette distinction est cruciale ? Parce qu'un crash report n'est pas une donnée comportementale : c'est un signal de défaillance technique qui ne dit rien sur qui est l'utilisateur. Légalement et éthiquement, on peut le collecter par défaut, à condition qu'il soit anonymisé (pas d'IP, pas d'identifiant device). En revanche, savoir que "cet utilisateur a lancé 3 Pomodoros aujourd'hui" est une donnée d'usage qui exige un consentement explicite. Mélanger les deux dans un seul outil "tout-en-un" force soit à demander un consentement bloquant pour les crashs (et donc perdre 30-50% des reports), soit à collecter des données comportementales sans accord. Deux outils = deux régimes de consentement clairs.
L'architecture
Pour l’architecture, j’ai repris la base de Keepio avec un Namespacing bien spécifique :
App.Core.Tracking
├── Service ← façade unique appelée par le reste de l'app
├── Event ← enum exhaustive de tous les événements
│ ├── SignalMapping ← Event → nom de signal + paramètres
│ └── SupportingTypes ← enums associées (PaywallTrigger, TabName, etc.)
└── Providers
├── Sentry ← wrapper du SDK Sentry
└── TelemetryDeck ← wrapper du SDK TelemetryDeck
Le Service est la seule chose que connaît le reste de l'app. Si demain je remplace TelemetryDeck par PostHog, je touche un seul fichier.
Ce qu'on met en place ici, c'est un pattern Façade + Adapters. La Façade (Service) expose une API stable et minimaliste au reste de l'app, une seule méthode track(_:). Les Adapters (Providers.Sentry, Providers.TelemetryDeck) traduisent cette API vers les SDKs tiers. Pourquoi c'est important au-delà de l'élégance ? Parce que les SDKs analytics ont une espérance de vie incertaine : TelemetryDeck pourrait fermer demain, Sentry pourrait changer ses tarifs, un nouvel outil pourrait apparaître. Si tu importes import Sentry ou import TelemetryDeck à 47 endroits dans ton app, tu es coincée. Avec cette architecture, l'import est confiné à deux fichiers, et les 47 callsites parlent à tracking.track(...). Le coût de migration tombe de "plusieurs semaines" à "une après-midi".
Étape 1 : Modéliser les événements comme une enum
Le piège du tracking, c'est les strings magiques disséminées partout. La parade : une enum exhaustive avec valeurs associées.
extension App.Core.Tracking {
enum Event {
case appLaunched, appFirstLaunched
case paywallViewed(trigger: PaywallTrigger)
case purchaseCompleted(plan: PremiumPlan)
case purchasedFailed(reason: PurchaseFailureReason)
// …
}
}
En faisant de cette manière, il est impossible d’envoyer un signal qui n’existe pas, les paramètres sont typés (pas de ["plan": "yerly"] qui passe en prod), et l'enum sert de documentation de ce qu'on mesure.
Le mapping vers TelemetryDeck est centralisé :
extension App.Core.Tracking.Event {
var signalName: String {
switch self {
case .appLaunched: return "App.Launch"
case .projectCreated: return "Project.Create"
case .pomodoroCompleted: return "Productivity.Pomodoro.Complete"
// ...
}
}
var parameters: [String: String] {
switch self {
case .projectCreated(let hasColor, let hasSymbol):
return [
"hasColor": String(hasColor),
"hasSymbol": String(hasSymbol)
]
// ...
default: return [:]
}
}
}
Pourquoi séparer la déclaration de l'enum et son mapping ? Parce que ce sont deux préoccupations différentes : Event répond à "qu'est-ce que mon app peut mesurer ?" (sémantique métier), SignalMapping répond à "comment je nomme ça côté TelemetryDeck ?" (convention externe). Si demain TelemetryDeck change sa convention de nommage, je modifie un seul fichier sans toucher à la liste des events. Si demain j'ajoute un event métier, je n'ai pas à parser un gros fichier monolithique.
Pourquoi PascalCase + dots dans signalName ? C'est la convention TelemetryDeck : leur dashboard regroupe automatiquement les signaux par préfixe. Project.Create, Project.Archive, Project.Delete apparaissent comme un cluster visuel, ce qui aide énormément à lire les tendances. Si j'envoyais project_created, archive_project, deleteProject, je perdrais ce groupement.
Pourquoi utiliser des valeurs associées plutôt qu'une [String: Any] en paramètre de track(_:) ? Parce que track("Project.Create", ["hasColor": true]) compile même si je tape "hasColr" ou si j'oublie complètement le paramètre. Avec case projectCreated(hasColor: Bool, hasSymbol: Bool), le compilateur refuse d'avancer tant que je n'ai pas fourni les deux booléens dans le bon ordre avec le bon type. C'est Swift qui devient mon schéma analytics, pas un wiki interne qui se périmera.
Étape 2 : Encapsuler chaque SDK dans un Provider
Sentry d'abord. Privacy-safe par défaut : pas d'IP, pas d'IDFV.
extension App.Core.Tracking.Providers {
enum Sentry {
static func start() {
#if DEBUG
return // les crashs de dev = bruit sur le dashboard prod
#endif
guard let dsn = dsnFromBundle(), !dsn.isEmpty else { return }
SentrySDK.start { options in
options.dsn = dsn
options.sendDefaultPii = false
options.environment = "production"
}
}
static func addBreadcrumb(category: String, message: String, data: [String: String]) {
#if !DEBUG
let crumb = Breadcrumb(level: .info, category: category)
crumb.message = message
crumb.data = data
SentrySDK.addBreadcrumb(crumb)
#endif
}
}
}
TelemetryDeck ensuite avec le même esprit :
extension App.Core.Tracking.Providers {
enum TelemetryDeck {
static func start() {
#if !DEBUG
guard let appID = appIDFromBundle(), !appID.isEmpty else { return }
let config = TDClient.Config(appID: appID)
TDClient.initialize(config: config)
#endif
}
static func signal(_ name: String, parameters: [String: String] = [:]) {
#if !DEBUG
TDClient.signal(name, parameters: parameters)
#endif
}
}
}
⚠️ si on appelle TelemetryDeck.signal() sans avoir appelé initialize() avant, le SDK fait un fatalError. Du coup, le #if !DEBUG doit envelopper les deux méthodes, pas juste start(). Idem pour Sentry.addBreadcrumb qui sinon spammait la console Xcode en debug.
Pourquoi enum plutôt que struct ou class pour les Providers ? Parce qu'un enum sans cases est en Swift un excellent "namespace inerte" : il ne peut pas être instancié (Sentry() ne compile pas), il ne porte pas d'état, il sert juste à regrouper des méthodes statiques. C'est la sémantique exacte qu'on veut : un Provider n'a pas d'identité, c'est un point d'accès à un SDK global. Utiliser class aurait permis l'instanciation et invité au mauvais usage.
Pourquoi lire le DSN/AppID depuis le Info.plist plutôt que de les hardcoder ? Deux raisons. La première est triviale : on ne veut pas committer un secret dans le repo (même si Sentry DSN n'est pas un secret au sens strict, c'est une bonne hygiène). La seconde est plus subtile : avoir la valeur dans Info.plist permet d'avoir des configurations différentes par schéma Xcode (Debug, Beta, Release) via les Build Settings — utile le jour où on veut un projet Sentry séparé pour les TestFlight.
Pourquoi sendDefaultPii = false ? Par défaut, Sentry collecte l'adresse IP de l'utilisateur (pour la géolocalisation approximative sur le dashboard). Sur Keepio, je n'en ai aucun besoin — je veux comprendre les crashs, pas savoir d'où ils viennent géographiquement. Désactiver ce flag est un choix de privacy-by-default : moins on collecte, moins il y a de risque de fuite, et moins on a de paperasse RGPD à gérer.
Pourquoi le #if !DEBUG doit envelopper signal() et pas juste start() ? C'est la leçon qui m'a coûté un crash en production lors d'un test interne. Le SDK TelemetryDeck implémente un contrat strict : si tu appelles signal() avant initialize(), c'est un fatalError immédiat (pas une exception qu'on peut catch, un crash net). Comme start() est skippé en debug, initialize() n'est jamais appelé, donc tout appel à signal() depuis un build debug crasherait l'app. La solution : skipper aussi signal() en debug. C'est cohérent avec l'intention (pas de tracking en debug) et ça évite un foot-gun.
Étape 3 : La façade qui gère le consentement
C'est le cœur du truc. Une seule classe @Observable, une seule méthode publique track(_:).
extension App.Core.Tracking {
@Observable
final class Service {
static let shared = Service()
/// Source of truth : UserDefaults
var analyticsEnabled: Bool {
didSet { handleAnalyticsToggle(oldValue: oldValue) }
}
init() {
self.analyticsEnabled = UserDefaults.standard.bool(forKey: Keys.analyticsEnabled)
}
/// Sentry: toujours. TelemetryDeck: seulement si consentement.
func start() {
Providers.Sentry.start()
if analyticsEnabled {
Providers.TelemetryDeck.start()
}
}
func track(_ event: Event) {
// Sentry reçoit un breadcrumb anonyme (utile en cas de crash plus tard)
Providers.Sentry.addBreadcrumb(
category: "tracking",
message: event.signalName,
data: event.parameters
)
guard analyticsEnabled else { return }
Providers.TelemetryDeck.signal(event.signalName, parameters: event.parameters)
}
private func handleAnalyticsToggle(oldValue: Bool) {
guard analyticsEnabled != oldValue else { return }
UserDefaults.standard.set(analyticsEnabled, forKey: Keys.analyticsEnabled)
if analyticsEnabled {
Providers.TelemetryDeck.start()
track(.analyticsOptedIn)
} else {
// Envoyer l'opt-out AVANT que le guard ne bloque tout
Providers.TelemetryDeck.signal(Event.analyticsOptedOut.signalName)
}
}
}
}
Deux subtilités importantes :
Les breadcrumbs Sentry sont envoyés même sans consentement analytics : ils n'ont pas de contenu PII, et c'est précieux pour comprendre la séquence d'actions qui a mené à un crash.
L'événement analyticsOptedOut est envoyé via le provider directement (pas via track()) parce que sinon le guard analyticsEnabled qui vient juste de passer à false le bloquerait. Détail qui a l'air mineur mais qui m'a permis de mesurer combien de personnes désactivent l'analytics.
Pourquoi @Observable ? Parce que le toggle "Analytics" dans les Settings de l'app est lié à tracking.analyticsEnabled. Avec @Observable (macro Swift 5.9+, basée sur Observation framework), SwiftUI redessine automatiquement le Toggle quand la valeur change, sans avoir à passer par @Published ni à exposer un ObservableObject. C'est moins de boilerplate et un modèle de réactivité plus précis (granularité par propriété au lieu de par objet).
Pourquoi static let shared ET @Environment en même temps ? Le shared sert aux contextes qui ne peuvent pas accéder à l'environnement SwiftUI : managers, services, code de lancement dans App.init(). L'@Environment sert aux Views et ViewModels — c'est plus testable (on peut injecter un fake) et c'est le standard SwiftUI. Garder une référence partagée n'est pas un anti-pattern ici parce que les deux pointent vers le même objet (Service.shared est injecté dans l'environnement au démarrage). C'est un singleton de fait, pas un global mutable.
Pourquoi UserDefaults comme source de vérité ? Parce que la préférence "analytics on/off" doit (a) persister entre les lancements, (b) être lisible avant que SwiftData/CloudKit ne soient initialisés (puisqu'on lit la valeur dans init(), avant tout le reste), et (c) ne JAMAIS quitter l'appareil. CloudKit aurait été une mauvaise idée : si l'utilisateur opt-out sur son iPhone, il ne s'attend pas à ce que ce choix se synchronise vers son iPad (et inversement) — c'est un choix par-device. UserDefaults est l'outil parfait pour ce cas.
Pourquoi le didSet plutôt que d'exposer une méthode setAnalyticsEnabled(_:) ? Parce que ça permet au binding SwiftUI $tracking.analyticsEnabled de fonctionner directement avec le Toggle. Si je devais appeler une méthode, j'aurais besoin d'un onChange(of:) à chaque endroit où je manipule la préférence. Le didSet centralise toute la logique de transition (sauvegarde UserDefaults + démarrage TelemetryDeck + envoi de l'event opt-in/out) en un seul endroit, déclenché peu importe d'où la valeur est modifiée.
Pourquoi envoyer le signal analyticsOptedOut AVANT que le guard ne le bloque ? C'est un cas tordu mais important. À l'instant T-1, l'utilisateur a analyticsEnabled = true. Il toggle en false. Le didSet se déclenche : analyticsEnabled vaut DÉJÀ false à ce moment. Si je passais par track(.analyticsOptedOut), le guard analyticsEnabled else { return } bloquerait immédiatement le signal et je n'aurais aucune trace de l'opt-out. En contournant via Providers.TelemetryDeck.signal(...) direct, je préserve cette mesure cruciale : combien de gens désactivent vraiment l'analytics ? C'est une donnée méta sur le consentement, et elle est elle-même éthique à mesurer parce qu'au moment de l'envoi, l'utilisateur avait encore le consentement actif.
Étape 4 : Le branchement au démarrage
Dans App.swift :
init() {
/// Sentry en premier — handler de signaux installé avant init du ModelContainer
App.Core.Tracking.Providers.Sentry.start()
// ... init du ModelContainer ...
/// Service unifié — Sentry.start() ici est no-op, TelemetryDeck démarre si consenti
tracking.start()
tracking.track(.appLaunched)
}
L'ordre est volontaire : Sentry doit être démarré avant tout code qui pourrait planter, notamment l'init de ModelContainer qui peut échouer (migrations SwiftData).
Pourquoi cet ordre précis ? Quand Sentry démarre, il installe un signal handler C qui intercepte les SIGSEGV, SIGABRT, SIGBUS et compagnie — c'est ce qui lui permet de capturer un crash, sérialiser la stack trace et la sauvegarder sur disque pour envoi au prochain lancement. Tant que ce handler n'est pas installé, un crash de l'app se traduit par "rien" : l'utilisateur voit l'app fermer, et toi tu n'apprends jamais ce qui s'est passé. Or, l'init de ModelContainer est un des endroits les plus risqués de l'app — une migration SwiftData ratée, un fichier de base corrompu, un schéma incompatible, et c'est fatalError immédiat. Démarrer Sentry AVANT cet appel garantit que ces crashs précoces remontent.
Pourquoi appeler Providers.Sentry.start() ET tracking.start() ? Le deuxième appel est un peu redondant côté Sentry (déjà démarré), mais il sert à initialiser TelemetryDeck (qui n'a pas besoin d'être lancé aussi tôt — l'analytics produit peut attendre que l'app soit fonctionnelle). C'est un compromis assumé : un peu de redondance dans le code pour une garantie forte côté crash reporting.
Pourquoi tracker .appLaunched ici plutôt que dans une View.onAppear ? Parce qu'onAppear se déclenche après le premier rendu, ce qui peut prendre quelques centaines de ms — voire ne jamais se déclencher si l'app crashe pendant le bootstrap. Mesurer le lancement dans init() capture toutes les sessions, y compris celles qui ne survivent pas au premier écran.
Étape 5 : Utilisation depuis les ViewModels
Une fois le Service injecté en @Environment(Service.self), le tracking devient invisible côté Views :
@Observable @MainActor
final class CreateProjectViewModel {
func save() {
// ... logique de création ...
tracking.track(.projectCreated(
hasColor: project.color != nil,
hasSymbol: project.symbol != nil
))
}
}
Le compilateur refuse tout ce qui ne match pas l'enum. Pas de typo, pas de paramètre oublié.
Pourquoi tracker des booléens "a-t-il une couleur ?" plutôt que la couleur elle-même ? C'est un choix de privacy-by-design. Savoir que "60% des projets créés ont une couleur custom" est une info produit utile (ça me dit que la personnalisation est valorisée). Savoir que "l'utilisateur X a choisi du bleu #4A90E2" est une donnée comportementale fine qui n'apporte rien et augmente la surface de risque RGPD. La règle que je m'applique : toujours envoyer le minimum d'information nécessaire pour répondre à la question produit. Si je ne sais pas formuler la question, je n'envoie pas le paramètre.
Pourquoi appeler tracking.track(...) depuis le ViewModel et pas depuis la View ? Parce que le tracking suit la logique métier, pas la logique d'affichage. Si demain je refactor CreateProject pour avoir deux Views (une iPhone, une iPad) qui partagent le même ViewModel, le tracking continuera de fonctionner sans duplication. À l'inverse, si je trackais dans la View, j'aurais le risque d'oublier l'appel dans une variante, ou de double-tracker depuis deux Views différentes.
Pourquoi cette approche scale bien ? Parce qu'ajouter un nouvel événement traçable prend exactement 3 étapes : (1) ajouter un case à l'enum Event, (2) ajouter une ligne dans signalName/parameters, (3) appeler tracking.track(.newCase) à l'endroit utile. Le compilateur t'oblige à compléter les étapes 1 et 2 (sinon les switch ne sont plus exhaustifs), donc tu ne peux pas oublier. C'est un design qui transforme la discipline en automatisme.
Ce que ça m'apporte concrètement sur Keepio
Sentry : je vois en temps réel les crashs en prod avec stack traces symboliquées, et les breadcrumbs me donnent le contexte (l'utilisateur a créé un projet, ouvert une note, puis crash dans le Pomodoro).
TelemetryDeck : je sais quelles fonctionnalités sont vraiment utilisées, d'où viennent les conversions du paywall, et quels widgets sont sous-utilisés. Ces données pilotent ma roadmap.
Les leçons
Sépare crash reporting et analytics produit. Ce sont deux problèmes différents avec deux modèles de consentement différents.
Façade + Adapters, toujours. Le code de l'app ne doit jamais importer un SDK tiers directement.
L'enum exhaustive est ton meilleur ami. Elle te coûte une demi-heure à mettre en place et t'épargne des mois de typos en prod.
#if !DEBUG partout dans tes providers*, pas juste à l'init. Sinon ton dashboard prod sera pollué par tes tests locaux et tes builds dev cracheront des fatalError mystérieux.
Privacy-first n'est pas qu'un argument marketing. sendDefaultPii = false sur Sentry, k-anonymity sur TelemetryDeck, opt-in explicite côté UI : c'est la base.

Commentaires
Aucun commentaire pour le moment. Sois le premier !