Traquer les actions sur les médias oEmbed dans Drupal avec Matomo

Comment mesurer l'impact de nos médias dans une démarche d'amélioration continue et d'éco-conception.

Dans une démarche d'amélioration des contenus ou d'éco-conception, il est généralement utile de pouvoir s'appuyer sur des métriques pour comprendre le comportement et les préférences des utilisateurs et utilisatrices de nos sites. Pour cela, nous utilisons déjà généralement un outil comme Matomo ou Google Analytics permettant de suivre les pages vues, les points d'entrée et de sortie de notre site, etc. Cependant, ces outils n'analysent pas par défaut les interactions au sein des pages donc il est nécessaire d'intervenir pour avoir des éléments plus fins pour décider si certains éléments de notre site sont pertinents ou pas et agir en conséquences (en les retirant ou en les disposant à un endroit plus approprié par exemple).

Google Analytics

Nous vous le précisions il y a déjà quelques temps mais pour des raisons de respect de la vie privée et du RGPD, nous ne recommandons pas l'usage de Google Analytics et lui préférons son alternative Open Source, Matomo.

Matomo Event Tracking

Alors que l'intégration de Matomo dans vos pages a pour objectif de simplement suivre les visites de vos utilisateurs et utilisatrices, l'Event Tracking peut vous permettre de suivre des actions se déroulant au sein même de vos pages. Qu'il s'agisse de la soumission d'un formulaire, de l'interaction avec un carrousel, du scroll jusqu'à un élément donné ou encore, cas qui nous intéresse aujourd'hui, de la lecture d'un média embarqué, cette fonctionnalité vous permet d'en savoir plus sur le comportement des personnes visitant vos pages.

L'usage de cette fonctionnalité nécessite quelques lignes de Javascript à intégrer à votre site après le script fourni par Matomo pour le tracking conventionnel. Par exemple :

mybutton.addEventListener('click', evt => {
  _paq.push(['trackEvent', 'Button', 'Click', evt.target.textContent]);
});

L'appel à la fonction "push" permet d'envoyer une requête à Matomo et type "trackEvent". Les autres paramètres sont les données que l'on souhaite enregistrer. Ces dernières sont totalement arbitraires donc n'hésitez pas à faire jouer votre créativité ! Dans l'ordre nous avons :

  • La catégorie de l'événement, ici "Button" mais pourrait tout aussi bien être "Link", "Media", "Carrousel", etc.
  • L'action relative à l'événement, ici "Click" mais pourrait être "Download", "Play", "Next slide", etc.
  • (optionnel) Le nom de l'événement, ici reprenant le contenu du bouton cliqué mais qui pourrait aussi être une URL, le nom d'un Media, le titre du carrousel, etc.
  • (optionnel) La valeur de l'événement, inutilisé dans notre exemple mais qui pourrait permettre d'enregistrer le titre de la slide du carrousel que l'un consulte par exemple.

Une fois ces événements enregistrés côté Matomo, il est possible de les voir apparaître dans le Journal des visites ou dans le suivi en Temps réel sous la forme d'une icône spécifique aux événements (un losange constitué de lignes verticales, par défaut).

Capture d'écran d'un extrait du journal des visites de Matomo
Capture d'écran d'un extrait du journal des visites de Matomo montrant un événement déclenché durant une visite.

 

Il est également possible de suivre les statistiques des événements de manière plus globale en se rendant dans la section Comportement > Évènements et de les explorer par catégorie, par action ou par nom.

Capture d'écran de l'interface listant les événements par catégories. La catégorie "Media" est ouverte et laisse apparaître trois actions : "Video play", "Audio play" et "Audio download" et le nombre d'enregistrements associés.
Capture d'écran de l'interface listant les événements par catégories.

 

Le problème des médias oEmbed

oEmbed est un format d'échange permettant d'intégrer dans un site du contenu en provenance d'un autre. Il existe actuellement 289 services déclarant officiellement supporter ce protocole mais il est possible d'intégrer du contenu venant d'un autre service non officiel.

Cependant, intégrer du contenu (et donc du code) d'un autre site dans le notre pose des problématiques évidentes de sécurité : comment savoir si ce site tiers est de confiance et comment s'assurer que les communication entre notre site et le site tiers ne seront pas interceptées pour injecter du code malicieux ? Bien que les services les plus connus puissent être considérés comme relativement sûrs, nul n'est à l'abri d'une attaque informatique et il est certain que compromettre la partie oEmbed d'un service comme Youtube pourrait avoir des impacts sur des millions de sites à travers le monde.

Par conséquent, pour se protéger la méthode la plus efficace est d'isoler le code du site tiers dans une iframe, idéalement utilisant un nom de domaine différent (au moins un sous domaine). En effet, être isolé dans une iframe permet d'éviter à un script malicieux de manipuler la page principale (en écoutant les saisies d'un formulaire de login par exemple) et le fait d'être sur un domaine séparé évite de laisser libre accès aux cookies qui seraient enregistrés par le site. C'est donc bien évidemment le choix qu'a fait Drupal pour intégrer des Media oEmbed dans les pages via les modules du cœur.

Malheureusement, le fait que notre Media soit servi depuis une iframe nous complique la vie car, toujours pour des raisons de sécurité, il n'est pas possible pour Javascript d'interagir directement avec le contenu d'une iframe. Il est donc obligatoire d'injecter le code qui nous permettra de réagir aux événements à l'intérieur de l'iframe pour ensuite appeler la méthode trackEvent vue précédemment.

Embarquer du JS dans l'iframe oEmbed

Première problématique rencontrée : les pages servies par Drupal dans son iframe dédiée à oEmbed ne peuvent pas contenir de Javascript par défaut. Il est donc nécessaire que nous altérions le fonctionnement du cœur pour pouvoir proprement injecter le script qui nous permettra de réagir aux événements. Cela se fait en deux étapes principales au sein de notre thème.

Premièrement, il faut surcharger le template media-oembed-iframe.html.twig pour y ajouter un placeholder permettant l'injection de Javascript (voir js-placeholder dans le code ci-dessous).

{#
/**
 * @file
 * Theme override to display an oEmbed resource in an iframe.
 */
#}
<!DOCTYPE html>
<html>
  <head>
    <css-placeholder token="{{ placeholder_token }}">
    <js-placeholder token="{{ placeholder_token }}">
  </head>
  <body style="margin: 0">
    {{ media|raw }}
  </body>
</html>

Ensuite il faut étendre le hook_preprocess_media_oembed_iframe() au sein du thème pour lui indiquer l'existence de ce nouveau placeholder.

/**
 * Implements hook_preprocess_media_oembed_iframe().
 */
function mytheme_preprocess_media_oembed_iframe(array &$variables) {
  // Allow JS to be injected in the oembed iframe.
  $variables['#attached']['html_response_attachment_placeholders']['scripts'] = '<js-placeholder token="' . $variables['placeholder_token'] . '">';
}

Une fois ces deux premières opérations effectuées, Drupal permettra d'attacher des fichiers JS issues de librairies dans l'iframe mais encore faut-il lui indiquer lesquelles et dans quelles conditions.

Au sein du même hook (ou d'une autre implémentation du même hook, selon la façon dont vous séparez votre code), vous devez donc attacher une librairie préalablement déclarée comme suit (par exemple) :

/**
 * Implements hook_preprocess_media_oembed_iframe().
 */
function mymodule_preprocess_media_oembed_iframe(&$variables) {
  if (strpos((string) $variables['media'], 'youtube.com') !== FALSE) {
    $variables['#attached']['library'][] = 'mymodule/oembed_youtube_observer';
  }
  elseif (strpos((string) $variables['media'], 'vimeo.com') !== FALSE) {
    $variables['#attached']['library'][] = 'mymodule/oembed_vimeo_observer';
  }
  else {
    $variables['#attached']['library'][] = 'mymodule/oembed_default_observer';
  }
}

Attention, la librairie devra être déclarée avec la clef "header: true" pour que les scripts soient bien inclus dans le template.

 

Communiquer avec la page parente

Comme vu précédemment, pour des raisons de sécurité, le Javascript exécuté dans une page ne peut pas communiquer directement avec le contenu d'une iframe pas plus que le Javascript exécuté dans une iframe ne peut communiquer directement avec le contenu de sa page parente. C'est pour cette raison qu'a été développée la méthode window.postMessage(), supportée par tous les navigateurs.

Le principe de cette méthode est de permettre aux scripts exécutés dans l'iframe d'envoyer à la page parente un Message qu'elle pourra interpréter si elle le souhaite et agir en conséquence. Dans notre cas, nous souhaitons que l'iframe informe la page parente d'une interaction sur le Media afin que celle-ci se charger de traquer l'événement dans Matomo.

Ainsi donc, dans le Javascript de l'iframe, nous allons chercher à réagir à un événement, comme par exemple le démarrage de la lecture d'une vidéo, et transmettre cela à la page principale. Cela pourrait prendre la forme suivante (simplifiée pour l'exemple) :

player.addEventListener('onStateChange', evt => {
  if (evt.data === 'playing') {
    const message = {
      action: 'video_playing',
      video: {
        url: evt.target.getVideoUrl(),
        id: evt.target.getAttribute('video-id'),
      }
    };
    window.parent.postMessage(message, '*');
  }
});

Côté page parente, il va être nécessaire de réagir à l'arrivée d'un nouveau message afin de le traiter. Cela va se faire à l'aide d'un écouteur d'événements de type "message" directement sur l'objet window.

window.addEventListener('message', evt => {
  if (evt.data.action && evt.data.action === 'video_playing') {
    window._paq.push(['trackEvent', 'Media', 'Video play', evt.data.video.url]);
  }
}, false);

Et voilà ! Un événement qui se produit dans l'iframe est bien enregistré dans Matomo par la page parente !

Sécurité

La méthode window.postMessage() permet d'envoyer des messages de n'importe quelle fenêtre vers n'importe quelle autre. Quelqu'un de malintentionné pourrait donc forger des messages envoyés vers votre page principale ! Il est très fortement recommandé d'utiliser le second paramètre de postMessage côté émetteur si les données que vous envoyez sont sensibles mais aussi le contenu de evt.origin côté récepteur pour vous assurer que le message provient bien d'une source sûre si votre traitement le justifie.

Dans le cadre de cet article, étant donné le sujet peu sensible, cet éléments ont été volontairement laissés de côté.

En savoir plus sur la sécurisation des échanges de messages entre fenêtres.

Combiner l'ensemble

Avec les éléments précédents, vous devriez tout avoir en main pour gérer vos propres cas. Voici un exemple concret et plus complet permettant de suivre la lecture d'une vidéo Youtube intégrée via oEmbed dans Drupal.

# mymodule.libraries.yml

oembed_youtube_observer:
  header: true
  js:
    '//www.youtube.com/iframe_api': {}
    js/oembed_youtube_observer.js: {}

matomo_media_tracking:
  js:
    js/matomo_media_tracking.js: {}
  dependencies:
    - core/drupal
    - core/once
# mymodule.module

/**
 * Implements hook_preprocess_media_oembed_iframe().
 */
function mymodule_preprocess_media_oembed_iframe(&$variables) {
  if (strpos((string) $variables['media'], 'youtube.com') !== FALSE) {
    $variables['#attached']['library'][] = 'mymodule/oembed_youtube_observer';
  }
}

/**
 * Implements hook_ENTITY_TYPE_view_alter().
 */
function mymodule_media_view_alter(array &$build) {
  $build['#attached']['library'][] = 'mymodule/matomo_tracking';
}
# js/oembed_youtube_observer.js

/**
 * Youtube Iframe API loaded callback.
 *
 * @see https://developers.google.com/youtube/iframe_api_reference
 */
function onYouTubeIframeAPIReady() {
  document.querySelectorAll('iframe').forEach(el => {
    const player = new YT.Player(el);
    player.addEventListener('onStateChange', evt => {
      // When playing for the first time.
      if (evt.data === YT.PlayerState.PLAYING && !player.wasPaused) {
        const message = {
          action: 'video_playing',
          video: {
            url: evt.target.getVideoUrl(),
            id: new URL(evt.target.getVideoUrl()).searchParams.get('v'),
            duration: evt.target.getDuration(),
          }
        };
        window.parent.postMessage(message, '*');
      }
      // On pause, store the state to prevent tracking unpausing.
      else if (evt.data === YT.PlayerState.PAUSED) {
        player.wasPaused = true;
      }
      // If the video ended reset the pause tracker.
      else if (evt.data === YT.PlayerState.ENDED) {
        player.wasPaused = null;
      }
    });
  });
}
# js/matomo_media_tracking.js

((Drupal, once, window) => {
  Drupal.behaviors.MatomoMediaTracking = {
    attach: function () {
      once('MatomoMediaTracking', 'html').forEach(el => {
        window.addEventListener('message', evt => {
          if (evt.data.action && evt.data.action === 'video_playing') {
            window._paq.push(['trackEvent', 'Media', 'Video play', evt.data.video.url]);
          }
        }, false);
      });
    },
  };
})(Drupal, once, window);

J'espère que cet article vous aura éclairé sur les difficultés que l'on peut rencontrer lorsque l'on souhaite identifier des actions effectuées sur des Media et vous aura apporté tous les éléments nécessaires pour que vous y parveniez à votre tour.

Votre commentaire

Le contenu de ce champ sera maintenu privé et ne sera pas affiché publiquement.
Votre adresse servira à afficher un Gravatar et à vous notifier des réponses. Votre commentaire sera anonymisé si ce billet est dépublié pendant plus de 3 mois.
Pour lutter contre le spam notre système enregistre votre adresse IP et votre adresse e-mail si vous la partagez.
Nous vous invitons à consulter notre politique de confidentialité pour comprendre les traitements faits de ces données et comment les rectifier.

À propos de Edouard

Expert technique

Après un premier contact douloureux avec Drupal en 2009 en autodidacte, j'ai suivi une formation qui m'a convaincu de mon choix technologique et m'a vraiment mis en selle. Durant plusieurs années suite à cela j'ai accompagné des entreprises locales dans le développement de leurs projets de toutes sortes, de la simple vitrine à l'intranet social en passant par le projet e-commerce.