Pourquoi tracker les utilisateurs ?

Pour commencer je ne suis pas un adepte du tracking, mais je dois reconnaitre que c'est un très bon outil pour aller toujours plus loin dans la compréhension de comment les utilisateurs utilisent notre application et améliorer notre UI/UX pour correspondre mieux adapté à leur usage réel par exemple en rendant plus accessibles des fonctionnalités souvent utilisées mais peu pratique d'accès et à l'inverse pourquoi pas retirer complète certaines fonctionnalités qui ne sont pas/plus utilisée.

À noter que je parle bien de fonctionnalité (feature), parce qu'on a très souvent dans les applications frontend moderne (souvent des SPA) des éléments qui ne génèrent aucune requête http, sont soit purement graphique (un bouton qui change la vue, des boutons pour filtrer/trier, etc.), soit des actions qui peuvent se déclencher depuis plusieurs endroits de l'application et dont on voudrait connaitre le point d'entrée le plus courant.

Souvent on compare le tracking aux logs. Je trouve que c'est une petite erreur. Les logs sont généralement des informations techniques : on cherche à suivre la santé de notre application. Même si je suis totalement d'accord avec le fait qu'on peut en tirer des informations métier. Par contre le tracking permet d'avoir des sortes de logs fonctionnels, car on obtient une information qui concerne le parcourt de l'utilisateur sur l'application et donc peut être exploité par le business (Product Owner, commerciaux, responsable marketing, etc.) ou les personnes en charge de l'UX.

Par contre attention à un point très important : le tracking ne doit pas être un bloqueur pour vos utilisateurs, surtout si c'est une application web publique. Par exemple, j'ai eu un abonnement internet Sosh à un moment, et le simple fait d'avoir une extension qui bloque les pubs et traqueurs (uBlock Origin dans mon cas) m'empêchait d'accéder à mes factures en lignes. J'imagine que l'appel à Google Analytics était placée par exemple juste avant l'appel au téléchargement de la facture et ça crachait l'application (de ce que j'ai pu comprendre des erreurs dans la console du navigateur).

Ce que je vous recommande c'est de toujours partir du principe que vos appels à votre outil de tracking pourra ne pas être disponible : l'important c'est pas le tracking mais que vos utilisateurs puissent utiliser l'application, sinon vous ne ferez plus de tracking car plus d'utilisateurs !

Sur ce passons à la technique ! 😉

Ajouter le client

Je vous laisse aller voir dans l'interface d'administration de Matomo pour récupérer le morceau de code à intégrer, il sera pré-configurer.

Pour ceux qui sont curieux, on obtient quelque chose dans ce genre-là :

<!-- Matomo -->
<script type="text/javascript">
  var _paq = window._paq = window._paq || [];
  _paq.push(['trackPageView']);
  _paq.push(['enableLinkTracking']);
  (function() {
    var u="//{$MATOMO_URL}/";
    _paq.push(['setTrackerUrl', u+'matomo.php']);
    _paq.push(['setSiteId', {$IDSITE}]);
    var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
    g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
  })();
</script>
<!-- End Matomo Code -->

Attention au $IDSITE qui sera automatiquement remplacé par le bon id si vous prenez ce script dans Matomo, mais que vous devrez compléter si vous le copier depuis ici.

Maintenant le simple fait de charger le site va suivre automatiquement le nombre d'utilisateur. Pour la suite je vais me concentrer sur les évènements qui ne sont pas les seuls éléments qu'on peut suivre avec Matomo mais c'est ce que j'ai le plus utilisé de mon côté.

Émettre un évènement

Pour émettre un évènement on va utiliser une fonction qui injecté sur l'objet global window _paq.push(['trackEvent', 'Category', 'Action', 'Name', 'Value']).

Plusieurs choses à noter :

  • utiliser un appel global comme ça c'est pas terrible
  • la fonction trackEvent s'appelle via un passage de chaîne de caractère dans un tableau
  • pareil pour les paramètres
  • comme _paq est injecté par un script qu'on ajoute à notre index.html, on a aucune garantie qu'il soit défini

Clairement on est pas face à l'API la plus developper friendly qu'on ait pu voir. Je vous propose d'améliorer un peu l'utilisabilité de cette API en la cachant derrière une API plus pratique.

// fp.ts
function isDefined<T>(x: T | undefined | null): x is T {
  return x !== null && typeof x !== 'undefined';
}

// matomo.ts
export const Matomo = {
    trackEvent(category: string, action?: string, name?: string, value?: unknown) {
      _paq?.push(['trackEvent', category, action, name, value].filter(isDefined))
    }
} as const;

Ici c'est déjà plus intéressant, car on a une vraie signature de fonction, on a un vrai objet porteur qui sera toujours défini, on ne risque pas d'erreur si _paq n'est pas injecté.

Émettre un événement typé

Personnellement je préfère aller encore une étape plus loin avec un typage de l'ensemble des événements de sorte à savoir facilement ce qui existe comme événement. Évidemment si vous n'êtes pas en TypeScript, cette partie n'a que peu d'intérêt pour vous.

// matomo.ts

interface CartEvent {
  category: 'Cart';
  action: 'Remove product' | 'Checkout';
}

interface QuantityCartEvent {
  category: 'Cart';
  action: 'Change quantity';
  name: 'Increase quantity' | 'Decrease quantity' | 'Set quantity';
  value: number;
}

interface ProductEvent {
  category: 'Product';
  action: 'Expand details' | 'Add to cart';
}

interface QuantityProductEvent {
  category: 'Product';
  action: 'Change quantity';
  name: 'Increase quantity' | 'Decrease quantity' | 'Set quantity';
  value: number;
}

type MatomoEvent = 
  | CartEvent 
  | QuantityCartEvent 
  | ProductEvent 
  | QuantityProductEvent;

export const Matomo = {
    trackEvent(event: MatomoEvent) {
      _paq?.push(['trackEvent', event.category, event.action, event.name, event.value].filter(isDefined))
    }
} as const;

En termes de code compilé on est assez proche de la version d'avant, par contre ici on a quelque chose de clairement défini en termes de type. De cette façon, on peut se laisser guider par l'auto-complétion et on est certain de ne pas avoir de faute de frappe ou plusieurs appels au même événement mais pas écrit de la même façon.

Émettre les événements

Maintenant qu'on a posé la structure pour avoir une API propre pour émettre nos événements, il faut les émettre !

Je pense que le plus simple pour avoir quelque chose de pratique c'est de passer par un middleware Redux pour le plus possible d'événement. Évidemment, la fonction trackEvent peut être appelé n'importe où dans l'application, mais ce n'est pas le mieux.

Si vous n'avez jamais créé de middleware Redux c'est très simple, c'est une fonction curifiée du type : store => nextMiddleware => action => result.

Donc je vous propose quelque chose comme ça :


export function MatomoReduxMiddleware(store: Store<AppState>) {
  return (next) => (action: AppAction) => {
    try {
      switch (action.type) {
        case '[Cart] Remove product':
          Matomo.trackEvent({ category: 'Cart', action: 'Remove product' });
          break;
        case '[Cart] Checkout':
          Matomo.trackEvent({ category: 'Cart', action: 'Checkout' });
          break;
        case '[Cart] Increase quantity':
          Matomo.trackEvent({
            category: 'Cart', 
            action: 'Change quantity',
            name: 'Increase quantity',
            value: action.step,
          });
          break;
        case '[Cart] Decrease quantity':
          Matomo.trackEvent({
            category: 'Cart', 
            action: 'Change quantity',
            name: 'Decrease quantity',
            value: action.step,
          });
          break;
        //...
      }
    } catch (err) {
      console.warn('Cannot emit Matomo event', err);
    }

    return next(action);
  }
}

Je ne vous fais pas un dessin, le code parle de lui-même. En fonctionnant comme ça vous isolez dans un coin les évènements Matomo sans polluer toute l'application avec ça.

Un petit DSL pour gérer les événements ?

C'est clairement une étape optionnelle, mais en passant par un DSL on peut rendre ce middleware plus expressif et plus rapide à lire.


export const MatomoReduxMiddleware = createMatomoMiddleware(
  on('[Cart] Remove product', () => ({category: 'Cart', action: 'Remove product'})),
  on('[Cart] Checkout', () => ({category: 'Cart', action: 'Checkout'})),
  on('[Cart] Increase quantity', (action) => ({
    category: 'Cart', 
    action: 'Change quantity', 
    name: 'Increase quantity',
    value: action.step,
  })),
  on('[Cart] Decrease quantity', (action) => ({
    category: 'Cart', 
    action: 'Change quantity', 
    name: 'Decrease quantity',
    value: action.step,
  })),
  ///...
);

function createMatomoMiddleware(...matchers: ActionEventMatcher[]) {
  return (store: Store<AppState>) => (next) => (action: AppAction) => {
    try {
      matchers
        .filter(matcher => matcher.actionType === action.type)
        .forEach(matcher => Matomo.trackEvent(matcher.eventCreator(action)));
    } catch (err) {
      console.warn('Cannot emit Matomo event', err);
    }

    return next(action);
  }
}

interface ActionEventMatcher {
  actionType: AppAction['type'];
  eventCreator: (action: AppAction) => MatomoEvent;
}

function on(
    actionType: AppAction['type'], 
    eventCreator: (action: AppAction) => MatomoEvent,
): ActionEventMatcher {
    return { actionType, eventCreator };
}

Et on peut sans souci imaginer de faire un DSL pour les tests unitaires aussi 😇

Conclusion

Comme souvent ce n'est que ma manière de faire, une manière parfaitement discutable mais d'expérience je sais que c'est une manière de faire qui fonctionne bien. Maintenant si vous structurez les choses différemment n'hésitez pas à partager je suis curieux ! 🤓

Sources:

Crédit photo : https://pixabay.com/illustrations/technology-analytics-business-data-6701404/