Une approche pratique et à faible risque pour rendre les applications React Native observables. Ce pilote capture les exceptions JavaScript, les temps de démarrage, les gels d'interface, les performances réseau et les résultats d'achat. Il définit des règles d'alerte et des tableaux de bord liés à l'expérience utilisateur, et valide le tout localement avant un déploiement large. Le module TypeScript modulaire, l'alerte côté serveur et les routines opérationnelles maintiennent le bruit à un niveau bas et renforcent la confiance.
Prérequis, versions et hypothèses
- React Native ≥ 0.73 (Hermes par défaut), Node ≥ 18, TypeScript ≥ 5.0, @react-native-async-storage/async-storage ≥ 1.21
- Plateformes : iOS 13+ / Android API 24+ ; builds Debug et Release ; Expo SDK 50+ (géré et dev client)
- Un seul bundle JavaScript, un seul processus, pas de CodePush dans le pilote (CodePush nécessite une réinitialisation de session lors du changement de bundle)
- Tous les collecteurs s'exécutent sur le thread JS ; pas de pont natif dans le pilote. La corrélation des plantages natifs est couverte dans le parcours d'extension.
Vue d'ensemble de l'architecture
App → Module Monitoring (Collecteurs → Redacteur → Tampon → Flusher) → Transport → API d'ingestion
Flux de données : démarrage de session → mise en file d'événements → vidage périodique/en arrière-plan → agrégation serveur → alertes/tableaux de bord. Le threading reste sur le thread JS ; la corrélation des plantages natifs sera ajoutée plus tard. Le module expose une petite API typée pour que le code produit reste propre.
Configuration et drapeaux de fonctionnalités
La configuration distante (monitoring.config.json) contrôle tout à l'exécution :
{
"endpoint": "https://example.com/monitoring",
"flushIntervalMs": 5000,
"maxBuffer": 500,
"maxBatchSizeBytes": 262144,
"uiFreezeThresholdMs": 500,
"uiFreezeIntervalMs": 100,
"piiPatterns": [
"\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b",
"\\b\\d{12,19}\\b",
"Bearer\\s+[A-Za-z0-9\\-._~+/]+=*",
"eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*",
"\\b(-?\\d+(\\.\\d+)?),\\s*(-?\\d+(\\.\\d+)?)\\b"
],
"piiAllowlist": ["user_id", "device_id"],
"enabledCollectors": ["jsExceptions", "startup", "uiFreeze", "network", "purchase"],
"samplingRate": 1.0,
"enableCompression": true,
"hmacKeyId": "prod-key-1",
"monitoringEnabled": true
}
Surcharge locale : stocker une configuration partielle dans AsyncStorage sous la clé monitor:config_override pour le développement.
API d'instrumentation (TypeScript)
Types principaux
export interface MonitoringConfig {
endpoint: string;
flushIntervalMs: number;
maxBuffer: number;
maxBatchSizeBytes: number;
uiFreezeThresholdMs: number;
uiFreezeIntervalMs: number;
piiPatterns: string[];
piiAllowlist: string[];
enabledCollectors: CollectorName[];
samplingRate: number;
enableCompression: boolean;
hmacKeyId: string;
monitoringEnabled: boolean;
}
export type CollectorName = 'jsExceptions' | 'startup' | 'uiFreeze' | 'network' | 'purchase';
export interface Event {
ts: number;
session_id: string;
type: 'event' | 'metric' | 'log';
level: 'info' | 'warn' | 'error';
name: string;
fields: Record<string, unknown>;
app: AppContext;
rn: RNContext;
device: DeviceContext;
event_id: string;
}
Les objets de contexte (AppContext, RNContext, DeviceContext) transportent la version, l'identifiant de bundle, le drapeau Hermes, la plateforme, la version OS et le modèle. Chaque événement inclut un UUID event_id généré côté client pour une ingestion idempotente.
Méthodes publiques
export const Monitoring = {
init: (config: Partial<MonitoringConfig>) => Promise<void>;
markAppReady: (screen: string) => void;
trackPurchaseAttempt: (fields: PurchaseEventFields) => void;
trackPurchaseSuccess: (fields: PurchaseEventFields) => void;
trackPurchaseFailure: (fields: PurchaseEventFields) => void;
recordEvent: (name: string, fields: Record<string, unknown>, level?: Event['level'], type?: Event['type']) => void;
setEnabled: (flag: boolean) => void;
disableCollector: (name: CollectorName) => void;
flush: () => Promise<void>;
closeSession: () => Promise<void>;
getDebugState: () => DebugState; // pour MonitoringDebugPanel
};
PurchaseEventFields utilise les unités mineures (centimes) et la devise ISO 4217. Les raisons d'échec correspondent à des compartiments actionnables : authentication_required, insufficient_funds, network_error, cancelled, invalid_payment_method, unknown.
Collecteurs en détail
Exceptions JavaScript
- Mécanisme :
ErrorUtils.setGlobalHandlerplus un proxyconsole.error. - Note Hermes : Le gestionnaire global fonctionne dans Hermes 0.73+ ; les piles minifiées conservent la structure des frames mais perdent les noms de variables. Téléverser les source maps vers le pipeline d'ingestion pour la symbolication.
- Fatal vs non-fatal : Drapeau
isFatalissu deErrorUtils;console.errorcapture les avertissements React non-fatals.
Temps de démarrage
tAppStart: Capturé au chargement du module viaperformance.now()(monotone). SiPerformance.markest disponible, marquerapp_start.markAppReady(screen): Émetapp.startupavecphase: 'first_screen_ready',startup_msetscreen.- À froid vs à chaud : Détecté via le drapeau
session.previous_unclean_exit(vrai si la session précédente ne s'est pas fermée proprement).
Détection des gels d'interface
- Algorithme : Boucle hybride
requestAnimationFrame/setTimeoutmesurant le budget de frame. - Configuration :
uiFreezeThresholdMs(défaut 500),uiFreezeIntervalMs(défaut 100). - Budget par frame : 16,67 ms à 60 fps ; lag =
now - last - intervalMs.
Surveillance réseau
- Wrapper fetch : Clone
Requestpour lecture du corps si nécessaire ; capture méthode, URL nettoyée, statut, durée, octets requête/réponse. - Nettoyage : Supprime les en-têtes
Authorization,Cookie,X-Api-Key; nettoie les paramètres de requête correspondant àtoken|key|secret|sig. - AbortSignal : Propage l'annulation ; enregistre
net.request_abortedavec la durée.
Flux d'achat
- Enum provider :
stripe | gplay | iap | other. - Montant en unités mineures (centimes). Devise : ISO 4217.
- Taxonomie d'échec :
authentication_required,insufficient_funds,network_error,cancelled,invalid_payment_method,unknown.
Transport et fiabilité
Tampon
- Structure : Tampon circulaire persisté dans AsyncStorage (
monitor:buffer) plus tête/queue en mémoire. - Persistance : À l'enfilement, sérialisation du tampon vers AsyncStorage (débouncé 500 ms). À l'initialisation, hydratation depuis le stockage.
- Limites :
maxBufferévénements (défaut 500) oumaxBatchSizeBytes(défaut 256 Ko), selon la première limite atteinte.
Vidage
- Planification : Intervalle
flushIntervalMs(défaut 5 s) plus déclencheurAppStateen arrière-plan. - Recul exponentiel : Base 1 s, plafond 60 s, gigue ±25 %.
- Lot : ≤ 50 événements ou 256 Ko ; gzip
Content-Encoding: gzipquandenableCompression=true. - Idempotence :
event_idgénéré côté client (UUID v4 viacrypto.randomUUID()avec polyfill pour RN).
Interrupteur d'arrêt
Configuration distante monitoring_enabled=false → flush() immédiat, arrêt de tous les collecteurs, vidage du tampon, désactivation des appels réseau.
Alertes et tableaux de bord (opérationnel)
Règles d'alerte PromQL
# Taux élevé d'exceptions JS
- alert: ReactNativeHighJSExceptionRate
expr: |
sum(rate(rn_js_exception_total[5m])) by (app_version)
/
sum(rate(rn_session_start_total[5m])) by (app_version)
> 0.01
for: 10m
labels:
severity: critical
team: mobile-oncall
annotations:
runbook_url: "https://example.com/runbooks/react-native-js-errors"
summary: "Taux d'exceptions JS > 1% pour {{ $labels.app_version }}"
# Pic de gels d'interface
- alert: ReactNativeUIFreezeSurge
expr: |
sum(rate(rn_ui_freeze_total{lag_ms>700}[5m])) by (device_model)
> 50
for: 5m
labels:
severity: warning
team: mobile-oncall
annotations:
runbook_url: "https://example.com/runbooks/react-native-ui-freezes"
summary: "Pic de gels d'interface sur {{ $labels.device_model }}"
# Régression de latence API
- alert: ReactNativeAPILatencyRegression
expr: |
histogram_quantile(0.95, sum(rate(rn_net_request_duration_seconds_bucket{url=~"api\\.example\\.com.*"}[15m])) by (le, endpoint))
> 1
for: 15m
labels:
severity: warning
team: backend-oncall,mobile-oncall
annotations:
runbook_url: "https://example.com/runbooks/api-latency"
summary: "API p95 > 1s pour {{ $labels.endpoint }}"
# Pic d'échecs d'achat
- alert: ReactNativePurchaseFailureSpike
expr: |
sum(rate(rn_purchase_failure_total[15m])) by (provider)
/
sum(rate(rn_purchase_attempt_total[15m])) by (provider)
> 0.1
for: 15m
labels:
severity: critical
team: payments-oncall
annotations:
runbook_url: "https://example.com/runbooks/purchase-failures"
summary: "Taux d'échec d'achat > 10% pour {{ $labels.provider }}"
Panneaux du tableau de bord Grafana
- Taux d'exceptions JS par version (série temporelle)
- Sorties non propres précédentes par appareil (graphique à barres)
- Démarrage p50/p95 (série temporelle)
- Carte de chaleur gels d'interface : Appareil × Lag (carte de chaleur)
- Latence API par endpoint (série temporelle)
- Entonnoir d'achat : Tentative / Succès / Échec par provider et raison (graphique à barres)
Vérification et tests
Inspection locale des événements
- Exception JS : Appuyer sur le bouton test →
throw new Error('test')→ vérifierjs.exceptionavec pile,isFatal=false. - Erreur console :
console.error('test error')→ vérifierjs.console_error. - Démarrage : Lancement à froid → vérifier
app.startupavecstartup_msdans la plage attendue de l'appareil. - Gel d'interface : Bouton debug bloquant le thread JS 1 s (
const t=Date.now(); while(Date.now()-t<1000){}) → vérifierui.freezeaveclag_ms ~ 1000. - Réseau : Fetch endpoints sains/échoués → vérifier
net.requestavec URL nettoyée, statut, durée. - Achat : Déclencher tentative/succès/échec → vérifier
purchase.attempt,purchase.success,purchase.failureavec provider, amount_cents, reason. - Pas de serveur ? Remplacer temporairement
flush()parconsole.log(JSON.stringify({events: payload}))et valider formes/nettoyage.
Contrôles de cohérence d'agrégation
- Attribution de session : Chaque événement inclut
session_id.previous_unclean_exitapparaît une fois au démarrage de session quand la session précédente n'était pas fermée. - Dénominateurs de taux : Émettre un événement explicite
session.startpour un dénominateur fiable. Sinon, approximer parsession_iddistincts par jour. - Nettoyage PII : Injecter
[email protected]dans un champ → confirmer[redacted_email].
Vérifications plateforme et Release
- Android Release : Piles minifiées conservées dans
js.exception; plantages natifs non capturés (l'heuristique unclean-exit aide). - iOS Release : Transition en arrière-plan (changement
AppState) déclenche le vidage. - Expo géré :
Monitoring.init()dans l'entrée ; proxy console fonctionne en dev/prod. Avecexpo-dev-client, modules natifs disponibles pour future capture de plantages natifs.
Tests unitaires (monitoring.test.ts)
import { Monitoring, redactPII, createBuffer } from './monitoring';
describe('Nettoyage PII', () => {
test('nettoie email', () => expect(redactPII('[email protected]')).toBe('[redacted_email]'));
test('nettoie carte de crédit (Luhn valide)', () => expect(redactPII('4242 4242 4242 4242')).toBe('[redacted_numeric]'));
test('nettoie JWT', () => expect(redactPII('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c')).toContain('[redacted_jwt]'));
test('préserve clés autorisées', () => {
const obj = { user_id: '123', email: '[email protected]' };
expect(redactPII(obj)).toEqual({ user_id: '123', email: '[redacted_email]' });
});
});
describe('Éviction tampon', () => {
test('supprime le plus ancien quand maxBuffer dépassé', () => {
const buf = createBuffer({ maxBuffer: 3 });
buf.push({id:1}); buf.push({id:2}); buf.push({id:3}); buf.push({id:4});
expect(buf.toArray().map(e=>e.id)).toEqual([2,3,4]);
});
});
describe('Retry/backoff vidage', () => {
test('backoff exponentiel avec gigue', async () => {
const flush = jest.fn().mockRejectedValueOnce(new Error('net')).mockResolvedValueOnce(undefined);
const { flushWithBackoff } = await import('./monitoring');
await flushWithBackoff(flush, { baseMs: 100, capMs: 1000, jitter: 0 });
expect(flush).toHaveBeenCalledTimes(2);
});
});
describe('Détection session non propre', () => {
test('émet previous_unclean_exit quand session précédente non fermée', async () => {
await AsyncStorage.setItem('monitor:last_session', JSON.stringify({ id: 'old', startedAt: 1, closed: false }));
const events: any[] = [];
Monitoring.recordEvent = (name, fields) => events.push({name, fields});
await Monitoring.init({});
expect(events.find(e=>e.name==='session.previous_unclean_exit')).toBeTruthy();
});
});
describe('Activation/désactivation collecteurs', () => {
test('disableCollector arrête détecteur gels UI', () => {
Monitoring.disableCollector('uiFreeze');
// vérifier qu'aucun événement ui.freeze n'est émis pendant lag simulé
});
});
Spéc E2E (Detox) (monitoring.e2e.ts)
describe('Monitoring E2E', () => {
beforeAll(async () => { await device.launchApp({newInstance: true, delete: true}); });
test('démarrage à froid émet app.startup', async () => {
const events = await getMonitoringEvents();
expect(events.find(e => e.name === 'app.startup' && e.fields.phase === 'first_screen_ready')).toBeTruthy();
});
test('arrière-plan déclenche vidage', async () => {
await device.sendToHome();
await waitForFlush();
const events = await getMonitoringEvents();
expect(events.find(e => e.name === 'session.background_flush')).toBeTruthy();
});
test('sortie non propre détectée au lancement suivant', async () => {
await device.terminateApp();
await device.launchApp({newInstance: true});
const events = await getMonitoringEvents();
expect(events.find(e => e.name === 'session.previous_unclean_exit')).toBeTruthy();
});
});
Porte CI : npm run test:monitoring dans le pipeline (exécute Jest + Detox sur émulateur/simulateur).
Panneau de debug (MonitoringDebugPanel.tsx)
Un composant React Native affichant l'état en direct et permettant à la QA de déclencher des événements test. Tous les boutons ont accessibilityLabel ; le panneau fonctionne avec TalkBack/VoiceOver.
import React from 'react';
import { View, Text, Button, ScrollView, StyleSheet } from 'react-native';
import { Monitoring } from './monitoring';
export const MonitoringDebugPanel = () => {
const [state, setState] = React.useState(Monitoring.getDebugState());
React.useEffect(() => {
const id = setInterval(() => setState(Monitoring.getDebugState()), 1000);
return () => clearInterval(id);
}, []);
return (
<ScrollView style={styles.container} accessibilityLabel="Panneau de debug Monitoring">
<Text style={styles.title}>Monitoring Debug</Text>
<Text>Activé : {String(state.enabled)}</Text>
<Text>Tampon : {state.bufferLength}/{state.maxBuffer}</Text>
<Text>Session : {state.sessionId}</Text>
<Button title="Lancer erreur test" onPress={() => { throw new Error('test'); }} accessibilityLabel="Lancer erreur test" />
<Button title="Erreur console" onPress={() => console.error('test error')} accessibilityLabel="Journaliser erreur console" />
<Button title="Simuler gel UI" onPress={() => { const t=Date.now(); while(Date.now()-t<1000){} }} accessibilityLabel="Simuler gel UI" />
<Button title="Fetch sain" onPress={() => fetch('https://httpbin.org/get')} accessibilityLabel="Fetch endpoint sain" />
<Button title="Fetch échoué" onPress={() => fetch('https://httpbin.org/status/500')} accessibilityLabel="Fetch endpoint échoué" />
<Button title="Tentative achat" onPress={() => Monitoring.trackPurchaseAttempt({provider:'stripe', amount_cents: 1000})} accessibilityLabel="Simuler tentative achat" />
<Button title="Vider maintenant" onPress={() => Monitoring.flush()} accessibilityLabel="Vider tampon maintenant" />
<Button title="Basculer activé" onPress={() => Monitoring.setEnabled(!state.enabled)} accessibilityLabel="Basculer monitoring activé" />
</ScrollView>
);
};
const styles = StyleSheet.create({ container: { padding: 16 }, title: { fontSize: 18, fontWeight: '600', marginBottom: 12 } });
Sécurité et confidentialité
Catégories PII gérées
- Email : Regex plus clés autorisées (
user_id,device_id). - Carte de crédit : Validation Luhn optionnelle (activable via
piiPatterns). - JWT : Motif base64url à trois segments.
- Tokens Bearer : Nettoyage en-tête
Authorization: Bearer <token>. - Coordonnées GPS : Motif
lat,londans les chaînes. - Adresses IP : Nettoyage côté serveur (pas côté client).
Rétention des données
- Client :
SESSION_TTL_MS(24 h) → suppression événements périmés à l'init. - Serveur : 30 jours événements bruts, 13 mois métriques agrégées.
Consentement et conformité
- Mapper le drapeau
scrub_piià l'état de consentement ; référence endpoint suppression RGPD/CCPA dans le runbook. - Sécurité transport : Épingler endpoint via config distante ; valider TLS ; signer charges utiles avec HMAC-SHA256 (
hmacKeyIddans config) pour rejeter la falsification. - Secrets : Ne jamais journaliser en-têtes/paramètres requête dans
net.request; nettoyer avant enfilement.
Budget performance et surcoût
| Métrique | Cible | Mesure |
|---|---|---|
| Surcoût collecteurs | < 1 ms/frame au repos | react-native-performance / Systrace |
| RAM tampon | < 2 Mo | Flipper React DevTools profileur mémoire |
| Impact batterie | < 5% sur 24 h | Android Battery Historian / iOS Energy Log |
| Charge réseau | ≤ 256 Ko/lot | Config maxBatchSizeBytes |
Étapes de profilage :
npx react-native-performance→ enregistrer trace 30 s.- Ouvrir dans Perfetto/Chrome DevTools → filtrer module Monitoring.
- Vérifier aucune chute de frame > 16 ms pendant le vidage.
Modes de défaillance, dépannage et retour arrière
| Symptôme | Cause | Détection | Atténuation | Retour arrière |
|---|---|---|---|---|
| Chutes frame > 16 ms | Détecteur gels UI trop fréquent | Systrace montre pics setInterval / rAF | Augmenter uiFreezeIntervalMs à 200 ms ; désactiver via config distante | disableCollector('uiFreeze') |
| OOM tampon | Hors ligne > 24 h, maxBuffer trop haut | Taille AsyncStorage > 5 Mo | Baisser maxBuffer à 200 ; persister seulement derniers 50 Ko | Vider monitor:buffer dans AsyncStorage |
| PII dans logs | Motif nettoyage manqué | Audit logs trouve email/token | Ajouter motif à piiPatterns ; redéployer config | Correctif à chaud via config distante |
| Fatigue alertes | Seuils trop serrés | > 10 alertes/jour par règle | Multiplier seuil par 3× p99 baseline | Ajuster seuil règle PromQL |
| Plantage natif invisible | Pilote JS seulement | previous_unclean_exit en pic, pas de js.exception | Intégrer react-native-exception-handler ou SDK vendeur | Corréler via session_id |
| Dérive horloge | Horloge appareil décalée | ts vs réception serveur delta > 5 min | Utiliser performance.now() pour durées ; serveur corrige | N/A (côté serveur) |
Lacune plantages natifs
Ajouter react-native-exception-handler (ou SDK vendeur) et émettre native.crash avec même session_id. Corréler dans le tableau de bord.
Dérive temporelle
Les durées utilisent performance.now() (monotone). Le serveur applique correction server_received_ts - client_ts pour timestamps absolus.
Scénario technique réaliste : Pic d'achats Black Friday
Conditions : Trafic ×5, défis 3DS Stripe, gels UI Android entrée de gamme, dégradation p95 API.
Parcours :
- Alerte déclenchée :
ReactNativePurchaseFailureSpike(taux échec 18% Stripe) +ReactNativeAPILatencyRegression(catalogue p95 2,1 s). - Triage tableau de bord : Entonnoir achat montre
authentication_required60% des échecs ; carte chaleur latence API montre régression/v1/catalogsur Android 10. - Atténuation :
- Drapeau fonctionnel
disable_heavy_animation→ réduit gels UI sur puces entrée de gamme. - Config distante : augmenter
flushIntervalMsà 10 s, activerenableCompressionpour réduire contention réseau. - Backend : ajouter couche cache catalogue ; Stripe : vérifier flux 3DS côté client.
- Post-incident : Collecter 7 jours données pilote → fixer nouveaux seuils à 3× p99 baseline ; mettre à jour runbook avec étapes dépannage 3DS.
Listes de contrôle opérationnelles
Quotidien
- Revue tableau de bord Stabilité : Tendances taux exceptions JS par dernier
app_version. - Vérification Performance : API p95 endpoints principaux ; surveiller régressions vs veille.
- Triage Alertes : Acquitter, ajouter contexte, lier aux runbooks.
Hebdomadaire
- Ajuster seuils : Réduire bruit, ajouter dimensions (modèle appareil, version OS).
- Revue backlog : Investiguer top 3 signatures exceptions ; ajouter garde-fous/corrections.
- Santé données : Échantillonner charges utiles événements → vérifier champs, nettoyage, attribution session.
Par Release
- Comparer beta vs production précédente : Taux exceptions, démarrage p50/p95, API p95.
- Vérifier appareils Android entrée de gamme pour outliers gels UI.
- Valider résultats flux achat par provider ; assurer mapping raisons échec vers compartiments actionnables.
Parcours d'extension
- Capture plantages natifs : Intégrer
react-native-exception-handlerou SDK vendeur ; lier àsession_id. - Métriques navigation/écrans : Enregistrer
navigation.transitionavecduration_ms,from,to. - Premier plan/arrière-plan : Suivre
app.foreground_duration_s,session.countpar canal release. - Échantillonnage : 1% logs verbeux (pile complète, actions Redux) via config
samplingRate.
Conclusion
Ce pilote offre une voie pratique et à faible risque pour surveiller une application React Native : capturer les exceptions JavaScript, les temps de démarrage, les gels d'interface, les performances réseau et les résultats d'achat ; définir des règles d'alerte et des tableaux de bord alignés sur l'expérience utilisateur ; et valider le tout localement avant un déploiement large. Commencer par le pilote, valider les signaux et le nettoyage, puis étendre la couverture et resserrer les seuils progressivement. Le module TypeScript modulaire, l'alerte côté serveur et les routines opérationnelles maintiennent le bruit bas, renforcent la confiance et rendent votre programme de monitoring React Native efficace et maintenable.