Server-side testing : quelle est l’architecture de Kameleoon ?

Server-side testing : quelle est l’architecture de Kameleoon ?

Cet article a pour objectif de présenter les considérations et conclusions qui nous ont amenés à définir l’architecture de nos SDKs serveur fin 2017. Le sujet de la performance en particulier a fait l’objet d’une réflexion approfondie, comme il l’avait été pour notre solution de testing et personnalisation côté client il y a 6 ans.

A l’époque nous avions dès le départ posé des fondamentaux solides (comme par exemple un unique call de téléchargement depuis le navigateur, regroupant l’ensemble du code nécessaire au fonctionnement de la plateforme, en sur-optimisant la taille du script) qui ont été ensuite consolidés par des innovations régulières (comme par exemple l’éradication de l’effet flicker ou, dernier exemple en date, l’utilisation de la compression Brotli plutôt que le traditionnel GZIP dans le transfert de nos scripts, réduisant le poids de celui-ci d’environ 30 %).

Les explications ci-dessous pourront être utiles aux équipes techniques intéressées par l’implémentation de tests A/B côté back-end pour comprendre rapidement tous les enjeux de ce sujet.

Le « bucketing » des visiteurs : Comment l’implémenter, ou pourquoi nous retrouvons la problématique Synchrone vs Asynchrone

Les opérations techniques qui sont nécessaires à l’implémentation d’un test A/B peuvent être regroupées en quatre grandes étapes :

  1. le ciblage et le « triggering » du test : il s’agit ici de déterminer quels visiteurs vont « rentrer » dans un test A/B, et à quel moment de leur visite.
  2. l’assignation d’une variante à un visiteur (ou « bucketing » en anglais) : à quelle variante ce visiteur va t-il être soumis, et – point très important – comment s’assurer que ce visiteur sera soumis à la même variante à l’avenir.
  3. la visualisation de la variante, avec son implémentation associée : une fois la variante connue pour un visiteur donné, il faut l’afficher et donc créer son HTML (on entend ici « visualisation » au sens général, car il est possible de définir des variantes modifiant la logique business, tels que des options de livraison différentes).
  4. le tracking du visiteur : pour visualiser les résultats du test, il est nécessaire d’enregistrer à la fois l’association de ce visiteur avec la variante (triplet de données : ID visiteur – ID experiment – ID variante) et les actions poursuivies par le visiteur (notamment son éventuelle conversion ou son éventuel achat au cours de la visite).

Dans une logique d’A/B testing server-side, les étapes 1. et 3. sont prises en charge directement par les équipes techniques du client. Ces dernières connaissent leur code source, elles peuvent donc immédiatement localiser l’endroit où se déclenche le test souhaité et y rajouter la logique souhaitée.

De même, elles maitrisent la génération du HTML des différentes variantes (généralement via un framework de templating). Cela changera d’ailleurs peu par rapport à la version du code originelle (sans A/B test), il suffit juste de rajouter une boucle switch (ou if / else, dans le cas courant de deux variantes) qui se base sur l’ID de la variante pour effectuer la branche correspondante.

Un framework d’A/B testing a donc peu de valeur ajoutée sur ces étapes, d’autant que la diversité des technologies utilisées rend toute approche générique illusoire. Par exemple, pour le triggering du test, la logique est totalement différente si l’on utilise une approche MVC, avec des controlleurs, comme avec Spring ou Symfony, ou si l’on utilise une approche fait maison, avec de l’URL rewriting en Apache par exemple.

Quant à la génération du HTML, il existe des dizaines voire des centaines de frameworks de templating différents (Smarty sur PHP, Jinja sur Python, Velocity sur Java pour n’en citer que quelques-uns).

Par conséquent, du point de vue d’un éditeur comme Kameleoon, un SDK de testing server-side doit se concentrer sur les étapes 2. et 4., en facilitant considérablement le travail des développeurs sur ces points-là.

Parlons d’abord du sujet du bucketing (étape 2), car nous verrons plus loin que le tracking du visiteur pose moins de problème.

De prime abord, l’implémentation paraît simple : il suffit de générer un nombre aléatoire et de l’utiliser pour déterminer si le visiteur est affecté à A ou à B. Sauf que cela pose un problème de taille : si le visiteur revient, il doit être affecté de nouveau à la même variante. C’est un fondamental de l’A/B testing sans lequel tout résultat est biaisé.

On ne peut donc pas exécuter un simple code déclenchant le test à chaque chargement de l’URL. Il faut d’une manière ou d’une autre stocker cette information et la récupérer plus tard pour ce visiteur. Or les systèmes IT traditionnels, notamment pour l’e-commerce, sont capables de stocker des informations pour des utilisateurs identifiés (donc loggés) mais gèrent généralement très mal le stockage d’information pour des visiteurs anonymes et non identifiés.

Heureusement, il s’agit là du cœur de métier de Kameleoon.

La manière la plus naturelle d’implémenter cela (mais aussi la plus naïve) consisterait à réaliser un appel à un serveur distant Kameleoon (par web-service REST, typiquement). Cet appel regarderait dans nos bases de données (qui sont conçues pour stocker des Téraoctets de données et répondre à des milliers d’appels par seconde) si le visiteur (identifié par un ID unique) est déjà associé à une variante. Si oui, on retourne cette valeur ; si non, on l’associe aléatoirement et on sauvegarde l’information en base.

Cette approche est d’autant plus naturelle qu’elle permet, au sein du même appel distant à nos serveurs, de traiter la problématique du tracking (étape 4.). Mais elle présente malheureusement un inconvénient de taille. Tant que cet appel n’est pas terminé, le développeur ne peut pas obtenir l’identifiant de la variante pour ce visiteur, et le code suivant l’appel (qui va implémenter l’étape 3., et typiquement être un simple if (variationId = 1) { // implement variation A} else { // implement variation B}) ne peut pas s’exécuter.

Nous avons donc créé un appel bloquant : on est obligés d’attendre le retour de l’appel serveur distant pour pouvoir continuer l’exécution du code. Tout ingénieur expérimenté essaiera d’éviter au maximum ce genre de situation, car l’application est de facto devenue dépendante d’une solution externe.

Imaginons un test A/B sur la homepage d’un site e-commerce. Tous les appels de chargement de cette page génèrent ainsi un appel à notre propre serveur Kameleoon ; si celui-ci tombe, le site e-commerce tombe aussi, car la homepage n’est plus jamais générée par le serveur hôte. On pourrait alors penser à rajouter des sécurités pour désactiver l’A/B test, notamment en cas de time-out (si le serveur distant met plus de 2 secondes à répondre, par exemple).

Mais dans tous les cas, même si tout fonctionne parfaitement, le temps de génération de la page côté serveur est alourdie par le temps de l’appel distant (au minimum 100 ms). Alors que l’un des avantages du testing server-side est en théorie de permettre une performance maximale, on commence donc par rajouter un temps d’exécution non négligeable. C’est inacceptable.

Nous avons donc réfléchi à la manière de résoudre ce challenge et de réaliser le bucketing sans faire appel à un call server bloquant. Cela posait nombre de problèmes, et il faut noter qu’en termes d’efforts de développement, c’est un investissement beaucoup plus lourd. Une approche par web-service REST peut en effet rester générique et est donc nettement plus simple à réaliser, puisque cela ne nécessite pas de développer un SDK par technologie.

Mais nous n’avons pas souhaité réaliser de compromis sur ce sujet, et avons finalement trouvé une solution satisfaisante. Sans donner de révélations sur les secrets techniques, le bucketing est réalisé directement à l’intérieur des algorithmes du SDK, immédiatement, et la méthode du SDK qui associe un visiteur à une variante retourne donc instantanément.

Et cette méthode est indépendante de tout serveur externe ou de base de données interne qui serait « embedded ». Le seul prix à payer pour cela (il y en a quand même un, mais acceptable) est l’impossibilité de modifier la répartition de la déviation entre les différentes variantes sans déclencher forcément un repooling (ce point, qui est d’ailleurs intéressant car méconnu en dehors des experts du testing, fera l’objet d’un article futur).

Ayant réussi à retirer tout couplage entre le serveur hôte client (hébergeant le SDK) et nos propres serveurs lors du bucketing, il fallait encore traiter la problématique du tracking (l’étape 4). Celle-ci nécessite forcément un appel à nos serveurs, c’est inévitable, mais heureusement, celui-ci peut tout a fait être réalisé de manière asynchrone.

Autrement dit, pour le tracking, on doit envoyer des informations à Kameleoon, mais la réponse du serveur est inutile (on parle parfois de calls « beacons » pour désigner des appels qui visent uniquement à transmettre des informations de tracking). La méthode d’assignation de variantes implémente donc, « under the hood», également la partie tracking via un call à un serveur Kameleoon. Mais celui-ci est effectué via un nouveau thread ; comme dit plus haut, la méthode retourne donc instantanément et est donc parfaitement optimisée.

Aller encore plus loin et problématiques de caching : Où exécuter le code du bucketing ?

Dans cette partie, nous abordons une problématique courante des sites Web à fort trafic : la plupart (pour ne pas dire la totalité) des sites utilisent une stratégie de mise en cache (dite caching), qui peut avoir lieu à plusieurs niveaux. Comme nous allons le voir, la mise en place d’A/B tests côté serveur peut présenter de sérieux challenges pour les différentes implémentations de cache couramment utilisées.

Ici, notre propos n’est pas de parler de comment nous avons traité le sujet dans Kameleoon – car il y autant de situations possibles que de clients et une plateforme n’est pas à même d’apporter une solution unique – mais de donner notre avis et de formuler quelques recommandations d’après les nombreuses expériences que nous avons eues sur le sujet.

Imaginons un site e-commerce à fort trafic. Le CMS gérant la partie e-commerce, qu’il soit basé sur Java, PHP ou sur n’importe quel autre stack technologique, doit générer des pages HTML dynamiquement. Par exemple, la homepage du site va contenir les offres promotionnelles du moment et mettre en avant certains produits.

Toutes ces informations proviennent des bases de données du back-end, et le code applicatif du CMS est, dans le cas standard, exécuté à chaque requête HTTP vers la homepage. Il rapatrie les informations de la base de données et renvoie la page web au visiteur. Mais évidemment, en réalité, on peut imaginer que cette page web n’est modifiée qu’une fois par jour. Dans ce cas, il est très intéressant, plutôt que d’exécuter du code Java / PHP à chaque requête HTTP, couplé à des requêtes en base (généralement SQL) coûteuses, de sauvegarder la page construite et de la resservir, identique, à chaque visiteur.

Il s’agit ici d’une simplification grossière, mais l’idée du caching est bien là : on transforme des réponses dynamiques en réponses statiques (toujours identiques), qui sont beaucoup plus légères à servir.

Les implémentations de ces mises en cache sont nombreuses. Mentionnons simplement deux cas assez courants qui illustreront bien nos propos : la mise en cache via CDN (Content Delivery Network), où le navigateur accède à un serveur du réseau du CDN qui aura une copie ; et la mise en cache via serveur cache HTTP (tels que Varnish, Squid) où typiquement un serveur front-end (nginx, lighttpd) sert une page mise en cache dans Varnish, plutôt que de la récupérer d’Apache ou Tomcat.

Dès que l’on veut implémenter un test, un problème apparaît : le bucketing, une fois de plus. En effet, pour réaliser le bucketing, il faut exécuter du code, et il n’est plus suffisant de servir simplement la page mise en cache. Il est intéressant de noter que les parties de génération de HTML peuvent être assez facilement réalisées sans perturber le cache.

Par exemple, avec Varnish, on peut imaginer mettre en place un header « Vary » au niveau du serveur front nginx ; ce header serait positionné en fonction de la valeur d’un cookie qui représenterait l’ID de la variante associée à un test. Il est donc possible de mettre en cache les différentes versions / variantes d’une expérience A/B. Mais en revanche, le bucketing lui-même reste problématique : pour chaque nouveau visiteur qui va rentrer dans un test, il est bel et bien nécessaire d’exécuter ce code de bucketing, qui ne peut absolument pas être mis en cache. Pour certains sites, cela peut être prohibitif.

Plusieurs stratégies sont possibles pour faire face à ce challenge. Un mix de client-side et de server-side est ainsi une option pertinente : on peut imaginer faire tourner le code de bucketing dans un script côté navigateur, et positionner en avance le (ou les) cookie(s) de variantes. Dans ce cas, on peut alors mettre en place derrière, côté back, plusieurs variantes en cache, par exemple via la méthode header « Vary » discutée précédemment.

Il faut noter que dans ce cas, le tracking des résultats doit également être réalisé côté client (puisque si ce n’est pas le cas, on retombe sur la problématique de nécessité d’exécution de code, avec l’impossibilité de mise en cache associée).

Cependant, cela n’est malheureusement pas toujours possible. Notamment dans le cas d’un test qui devrait avoir lieu sur la première page du parcours d’un visiteur (tel la homepage, ou même une page profonde qui serait visitée directement depuis Google), puisque dans ce cas, nous avons besoin de l’information de variante avant même que la première page (et donc le script de bucketing) ne soit chargée dans le navigateur client !

Pour ces cas difficiles où la performance maximale est recherchée, et où une implémentation server-side serait souhaitée (NB : il faudrait vraiment se poser la question de la légimité de la méthode server-side, car il peut être plus pertinent alors de recourir à une approche full client-side), notre recommandation est de positionner le code de bucketing le plus en amont possible côté serveur. Par exemple, au niveau du front-end nginx, plutôt que via nos SDKs serveur.

On pourrait ainsi imaginer l’écriture d’un module nginx réalisant le bucketing et le « branchage » associé, alors que la génération du HTML serait traditionnellement déléguée au back-end Apache / Tomcat et mise en cache. Un module nginx sera nécessairement beaucoup plus performant que l’appel à nos SDKs, quel qu’en soit le langage, et devrait répondre positivement à un besoin de performance extrême. Nous réfléchissons d’ailleurs actuellement chez Kameleoon à l’implémentation officielle d’un module de ce type.

Autre option intéressante en guise de conclusion, positionner le bucketing au niveau du CDN (qui est, dans un schéma de flot des requêtes HTTP, en quelque sorte l’équivalent d’un serveur nginx front, mais encore plus en amont). Cloudflare, un CDN très innovant, a ainsi annoncé il y a quelques mois la mise à disposition de workers JavaScript. Ce concept permet d’exécuter du code « on the edge » : comprendre au niveau du CDN, lorsque la requête HTTP du visiteur arrive sur un serveur Cloudflare après l’aiguillage DNS. Ainsi, il est très simple d’implémenter un code de bucketing en NodeJS, qui s’appuie sur la vaste infrastructure IT de Cloudflare (milliers de serveurs) et qui sera en mesure de répondre aux problématiques de scaling et de performance les plus exigeantes.

Jean-Noël Rivasseau

Jean-Noël is Kameleoon’s founder and CTO. A graduate of Ecole Polytechnique and University of British Columbia, he specializes in Machine Learning, Natural Language Processing and Data Mining.