Recherche interne PrestaShop : mesurer CTR, zéro résultat et conversion

Guide pratique pour logger impressions et clics, calculer CTR/CTR@k, surveiller zéro résultat et attribuer conversions via hooks, schéma SQL et intégrations analytics.

Écrans d'ordinateur affichant des graphiques et des codes pour l'analyse PrestaShop.

Table des matières :

  1. CTR, zéro résultat, conversion : définitions exploitables (et pourquoi le cœur PrestaShop ne suffit pas)
  2. Instrumenter la recherche PrestaShop via les hooks de Product Listing (PS 8.1 / 9.0)
  3. Capturer les clics sur résultats (position, produit, device) sans casser le thème
  4. Stockage des événements : schéma SQL minimal, indexation, rétention (RGPD)
  5. Calculer CTR et “zéro résultat” : SQL reproductible + garde-fous
  6. Attribuer la conversion à la recherche : last-touch, assistée, fenêtre temporelle
  7. Brancher GA4 / Matomo et industrialiser : événements standard + export API + alerting

CTR, zéro résultat, conversion : définitions exploitables (et pourquoi le cœur PrestaShop ne suffit pas)

PrestaShop (8.1.x comme 9.0.x) ne fournit pas nativement une mesure fiable de la performance de la recherche interne côté front : pas de CTR par requête, pas de taux de “zéro résultat” contextualisé, pas d’attribution conversionnelle exploitable sans instrumentation additionnelle. Le module historique de stats de recherche (quand présent selon les distributions) n’a jamais été conçu comme un système d’analytics : absence de notion de position (ranking), pas de dédoublonnage de session, pas de corrélation avec le panier/commande.

Le point bloquant n’est pas “un manque de graphiques” : c’est un manque de modèle de données. La recherche interne est un parcours, et un parcours se mesure par événements chaînés (impression → clic → ajout panier → achat), pas par une simple liste de termes.

On parle ici de trois KPI qui doivent reposer sur un modèle d’événements (event-based) et non sur des agrégats “à la volée” :

  • Impression de résultats : un utilisateur voit une liste de produits en réponse à une requête (avec un results_count et, idéalement, la liste ordonnée des IDs produits affichés).
  • Clic : l’utilisateur clique un produit depuis cette liste, avec une position (position = 1..N) et un identifiant de requête (search_id).
  • Conversion : commande validée (et éventuellement add-to-cart) attribuée à une ou plusieurs recherches.

D’un point de vue définition, le CTR recherche se décline en pratique en deux métriques distinctes : (1) CTR “listing” = nb recherches ayant au moins un clic / nb recherches avec résultats et (2) CTR@k = clics sur positions <= k / impressions positions <= k, utile pour comparer des moteurs (core, Elasticsearch, Meilisearch). Le taux de zéro résultat = nb recherches avec results_count = 0 / nb recherches (en filtrant les bots). La conversion liée à la recherche doit être explicitement typée (last-touch, assistée, fenêtre temporelle), sinon vous obtiendrez des chiffres impossibles à comparer entre versions et canaux.

Pour rendre ces KPI réellement exploitables, ajoutez systématiquement quelques dimensions minimales (sans tomber dans l’usine à gaz) :

  • contexte boutique : id_shop, id_lang, devise (si multi-devises), éventuellement id_country si votre catalogue varie selon zone
  • contexte de navigation : page, pagination, tri, filtres actifs (si vous avez du faceted search)
  • identité pseudonyme : id_guest (FO) + id_customer si connecté
  • contexte “device” : catégorie (mobile/desktop) via largeur viewport ou user agent côté client (évitez de stocker l’UA complet en base si vous n’en avez pas besoin)
  • qualité de la requête : longueur, présence de chiffres, accents/apostrophes, requête “vide” ou bruit (ex. ***)

Enfin, un détail très “terrain” (et très fréquent en FR/BE/CH) : normaliser les termes de recherche. Sans normalisation, vous multipliez artificiellement les variantes :

  • “cafe moulu”, “café moulu”, “cafe-moulu”, “café moulu”
  • “t-shirt”, “tee shirt”, “t shirt”
  • “l’oreal” vs “loreal” (apostrophe typographique)

Vous n’êtes pas obligé de tout faire dans PrestaShop : l’essentiel est de logger le terme brut + une version normalisée (minuscule, trim, espaces multiples, éventuellement suppression diacritiques) pour agréger proprement.

« We propose a taxonomy of Web search queries into three categories: informational, navigational, and transactional. » — Andrei Broder, A taxonomy of web search (SIGIR Forum, 2002)

Cette taxonomie est utile côté e-commerce : une requête “transactionnelle” (“nike pegasus 41 42”) ne se pilote pas comme une requête “informationnelle” (“comment choisir pointure running”). Sans cette distinction, vous mélangez des CTR “normaux” (informationnel) et des CTR “anormaux” (transactionnel), et vos optimisations (synonymes, ranking, merchandising) partiront dans le décor.

Un dernier rappel côté conformité : une requête peut devenir une donnée personnelle si elle identifie directement ou indirectement une personne (ex. email, téléphone, nom + ville). Le RGPD définit les “données à caractère personnel” dans le Règlement (UE) 2016/679, article 4. Concrètement : loggez ce qui est utile, et définissez une rétention claire.

Instrumenter la recherche PrestaShop via les hooks de Product Listing (PS 8.1 / 9.0)

Contexte versions : les exemples ci-dessous ont été validés sur PrestaShop 8.1.7 + PHP 8.2 et PrestaShop 9.0.2 + PHP 8.3 (thème Classic). Le front reste majoritairement legacy, mais les pages de listing (catégorie, recherche, nouveautés…) reposent sur le pipeline ProductListingFrontController et un ProductSearchProvider. C’est précisément à cet endroit que vous devez capter la requête, la pagination, le tri, et le results_count.

Le point d’accroche le plus propre (indépendant du thème) est le hook déclenché après la requête du provider. Selon version, vous le trouverez sous la forme actionProductSearchProviderRunQueryAfter (et souvent un ...Before). L’idée : au moment où PrestaShop a déjà calculé la réponse de recherche (et donc le total de résultats), vous créez un search_id et vous enregistrez un événement “impression”. En complément, vous pouvez fallback sur la capture du paramètre s/search_query via Tools::getValue() sur les shops ayant des overrides non standards.

Exemple minimal côté module (structure simplifiée) :

// PS 8.1/9.0 - PHP 8.2+
public function hookActionProductSearchProviderRunQueryAfter(array $params): void
{
    $query = $params['query'] ?? null;      // ProductSearchQuery
    $result = $params['result'] ?? null;    // ProductSearchResult

    if (!$query || !$result) {
        return;
    }

    $searchString = method_exists($query, 'getSearchString') ? (string) $query->getSearchString() : '';
    if ($searchString === '') {
        return;
    }

    $total = method_exists($result, 'getTotalProductsCount') ? (int) $result->getTotalProductsCount() : 0;

    // Identité visiteur : id_guest est souvent le meilleur compromis FO
    $idGuest = (int) ($this->context->cookie->id_guest ?? 0);
    $idCustomer = (int) ($this->context->customer->id ?? 0);

    // Persister un enregistrement "search_impression" avec total + pagination + tri
    $this->searchLogger->logImpression([
        'id_shop' => (int) $this->context->shop->id,
        'id_lang' => (int) $this->context->language->id,
        'id_guest' => $idGuest,
        'id_customer' => $idCustomer,
        'search_term' => $searchString,
        'total' => $total,
        'page' => (int) ($query->getPage() ?? 1),
        'results_per_page' => (int) ($query->getResultsPerPage() ?? 12),
        'order_by' => (string) ($query->getSortOrder()?->toLegacyOrderBy() ?? ''),
        'order_way' => (string) ($query->getSortOrder()?->toLegacyOrderWay() ?? ''),
    ]);
}

Point critique : ce hook tourne sur toutes les pages de listing qui utilisent le provider. Vous devez donc filtrer le contexte “recherche interne” pour éviter de logger les catégories. Sur Classic/legacy, un filtre pragmatique peut être, par exemple :

  • controller : php_self === 'search' (ou Tools::getValue('controller') === 'search')
  • présence d’un terme ($query->getSearchString() non vide)
  • et/ou détection de route (search vs category), selon votre routing

Autre point d’attention : si vous avez un module de navigation à facettes (type “Faceted search”), certaines interactions déclenchent des requêtes en AJAX sur le même pipeline. C’est souvent une bonne chose : vous voulez justement enregistrer une nouvelle impression quand le listing affiché change (tri, filtres, page). Il faut juste l’assumer dans votre modèle, en stockant le couple (terme + filtres + tri + page) et en évitant de considérer cela comme une “nouvelle intention” utilisateur.

Pour rendre l’instrumentation utile dès le départ, visez au minimum cette checklist :

  • search_id unique et stable (id auto-incrément, UUID/ULID… peu importe, tant que vous savez le recoller)
  • terme brut + terme normalisé
  • total résultats + nombre rendu (si possible)
  • pagination + tri
  • identifiant pseudonyme (id_guest) et id_customer si disponible
  • timestamp en ms (utile pour séquencer impression/clic)

Ne négligez pas non plus les perfs : pas de requêtes SQL supplémentaires coûteuses ici. Si vous devez enrichir (stock, prix, attributs), faites-le en asynchrone dans votre pipeline d’analytics, pas dans la requête front.

Pour aller plus loin côté qualité des résultats (tolérance aux fautes, suggestions live), vous pouvez chaîner avec l’article : Recherche interne PrestaShop : optimiser suggestions live et tolérance aux fautes. L’instrumentation décrite ici sert ensuite à mesurer l’impact réel de ces optimisations.

Capturer les clics sur résultats (position, produit, device) sans casser le thème

Logger l’impression sans logger les clics ne donne qu’un taux de zéro résultat. Pour mesurer le CTR, il faut instrumenter les clics depuis la grille de résultats. PrestaShop ne fournit pas de hook “clic produit depuis la recherche” côté front, donc vous avez deux options : (1) instrumentation JS (recommandée), (2) instrumentation via redirect intermédiaire (à éviter, ça dégrade UX/SEO/perf).

Approche robuste : au moment du rendu du listing, injectez un search_id dans le DOM (attribut data-search-id sur le container) et un data-position / data-id-product sur chaque miniature. Ensuite, délégation d’événement click et envoi d’un beacon vers un front controller de module (ou vers votre collector). Le navigator.sendBeacon() est adapté : non bloquant, survive au changement de page, faible overhead.

Exemple de payload côté front (à adapter au thème Classic ou à votre design system) :

document.addEventListener('click', (e) => {
  const a = e.target.closest('a.product-thumbnail, a.product-title');
  if (!a) return;

  const item = a.closest('[data-id-product][data-position]');
  const container = a.closest('[data-search-id]');
  if (!item || !container) return;

  const payload = {
    search_id: container.dataset.searchId,
    id_product: item.dataset.idProduct,
    position: item.dataset.position,
    ts: Date.now(),
  };

  navigator.sendBeacon('/module/yourmodule/searchclick', JSON.stringify(payload));
});

Deux pièges classiques :

1) la position doit être calculée de façon cohérente. Si vous instrumentez data-position “dans la page” (1..12), mais que vous voulez un CTR@k global, vous devez ajouter l’offset : global_position = (page-1)*resultsPerPage + position. Sinon, comparer “CTR@10” entre page 1 et page 2 n’a aucun sens.

2) les clics sur quickview, wishlist, add-to-cart depuis la grille doivent être différenciés du clic “navigation vers la fiche produit”, sinon votre CTR devient un mélange d’intentions. Une façon simple est d’ajouter un champ click_type (product_link, quickview, add_to_cart) et de ne calculer le CTR “ranking” que sur product_link.

Sur mobile, pensez aussi aux taps multiples et aux doubles envois : dédupliquez côté serveur (UNIQUE(search_id, id_product) ou fenêtre de temps). Et côté “device”, restez minimaliste : une catégorie device_class déduite de la largeur d’écran (ex. <768 mobile) suffit souvent pour segmenter un problème d’ergonomie sans collecter trop d’empreintes.

Si vous utilisez un moteur externe, mesurez aussi l’impact sur la perf et la stabilité (timeouts, index désynchronisé). Les intégrations typiques sont détaillées ici : Recherche interne PrestaShop : intégrer Meilisearch pour booster la conversion et PrestaShop ElasticSearch : accélérer la recherche produit sur grands catalogues.

Stockage des événements : schéma SQL minimal, indexation, rétention (RGPD)

Le stockage “dans PrestaShop” n’a pas vocation à devenir votre data warehouse, mais vous avez besoin d’un buffer fiable pour ne pas dépendre d’un tag manager ou d’une solution externe. En pratique, une table InnoDB dédiée suffit si elle est bien indexée et purgée. Exemple : ps_search_impression (1 ligne par requête vue) et ps_search_click (0..n lignes). Utilisez un identifiant search_id (UUID/ULID ou bigint auto-incrément) et stockez les dimensions minimales : shop, langue, guest/customer, terme, total, page, tri, timestamp.

Schéma simplifié (MySQL 8) :

CREATE TABLE ps_search_impression (
  id_search BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  id_shop INT UNSIGNED NOT NULL,
  id_lang INT UNSIGNED NOT NULL,
  id_guest INT UNSIGNED NOT NULL,
  id_customer INT UNSIGNED NULL,
  search_term VARCHAR(255) NOT NULL,
  total_results INT UNSIGNED NOT NULL,
  page SMALLINT UNSIGNED NOT NULL DEFAULT 1,
  results_per_page SMALLINT UNSIGNED NOT NULL DEFAULT 12,
  order_by VARCHAR(32) NOT NULL DEFAULT '',
  order_way VARCHAR(4) NOT NULL DEFAULT '',
  created_at DATETIME(3) NOT NULL,
  PRIMARY KEY (id_search),
  KEY idx_term_shop_date (id_shop, created_at, search_term),
  KEY idx_guest_date (id_guest, created_at)
) ENGINE=InnoDB;

CREATE TABLE ps_search_click (
  id_search BIGINT UNSIGNED NOT NULL,
  id_product INT UNSIGNED NOT NULL,
  position INT UNSIGNED NOT NULL,
  created_at DATETIME(3) NOT NULL,
  PRIMARY KEY (id_search, id_product),
  KEY idx_search_pos (id_search, position)
) ENGINE=InnoDB;

Quelques améliorations “pratiques” selon volume :

  • si vous avez beaucoup d’événements, indexez aussi created_at seul (pour purges rapides), ou purgez par tranches (ex. par semaine)
  • si votre top requêtes est important, ajoutez une colonne search_term_norm (VARCHAR) pour grouper sans recalcul
  • si vous voulez un CTR@k fiable sans approximation, stockez aussi le nombre réellement rendu (rendered_count) et/ou une liste compressée des IDs affichés (JSON court). Gardez-le optionnel : l’objectif est de rester léger côté front.

RGPD / sécurité : une requête de recherche peut contenir des données personnelles (“dupont”, email, téléphone). Vous devez donc documenter la finalité, définir une durée de conservation, et idéalement pseudonymiser (hash + sel pour les analyses globales, conservation du brut limitée). Pour les identifiants, id_guest est déjà un pseudo-identifiant PrestaShop ; ne stockez pas d’IP en clair sauf nécessité (et dans ce cas, chiffrement + rotation). Pour les endpoints de collecte (beacon), protégez-vous contre l’abus : rate limiting (reverse proxy), validation stricte du JSON, et refus de payloads trop gros.

Une politique simple (à adapter à votre contexte et après validation interne) est par exemple :

Donnée Utilité Recommandation pratique
search_term brut debug + synonymes “humains” conserver court (ex. 90 jours) puis supprimer/anonymiser
search_term_norm agrégation conserver plus longtemps si non ré-identifiant
id_guest / id_customer attribution session/commande conserver aligné avec vos durées cookies/compte
click/impression horodatés CTR, monitoring purger par fenêtre (ex. 13 mois max si vous alignez sur analytics)

Si vous préférez sortir rapidement les événements vers un pipeline (Kafka/Elastic), loggez en JSON structuré côté serveur et shippez via Filebeat/Logstash (voir : Logstash : plugins Input, Filter, Output pour Elasticsearch et Kafka). L’objectif est le même : un search_id stable et des événements horodatés.

Calculer CTR et “zéro résultat” : SQL reproductible + garde-fous

Le taux de zéro résultat est trivial à calculer, mais il devient vite trompeur si vous ne segmentez pas. Exemple de requête (sur 7 jours) :

SELECT
  COUNT(*) AS searches,
  SUM(total_results = 0) AS zero_results,
  ROUND(100 * SUM(total_results = 0) / COUNT(*), 2) AS zero_result_rate_pct
FROM ps_search_impression
WHERE created_at >= NOW() - INTERVAL 7 DAY
  AND id_shop = 1;

Ce chiffre doit être comparé à périmètre constant : langue, device, source (interne vs bots), et moteur (core vs ES/Meilisearch). Très souvent, le “zéro résultat” explose sur mobile à cause de fautes de frappe et d’autocomplete mal calibré, ou sur certains langages où le stemming/stop-words est bancal.

Garde-fous utiles (souvent oubliés) :

  • exclure les requêtes “bruit” : termes trop courts (LENGTH(search_term) < 2), uniquement ponctuation, ou répétitions
  • surveiller les pics anormaux : beaucoup de recherches uniques en quelques secondes avec id_guest = 0 (visiteurs sans cookie → bots fréquents)
  • segmenter par id_lang : un taux correct en FR peut masquer un problème complet en NL/DE si votre catalogue est partiellement traduit

Pour passer du constat (“on a X% de zéro résultat”) à l’action, listez les requêtes les plus fréquentes en zéro résultat. Cela alimente directement un backlog “synonymes / redirections / enrichissement attributs” :

SELECT
  search_term,
  COUNT(*) AS searches
FROM ps_search_impression
WHERE created_at >= NOW() - INTERVAL 30 DAY
  AND id_shop = 1
  AND total_results = 0
GROUP BY search_term
HAVING COUNT(*) >= 10
ORDER BY searches DESC
LIMIT 50;

Pour le CTR, commencez par un CTR “binaire” : une recherche a-t-elle généré au moins un clic ?

SELECT
  COUNT(*) AS searches_with_results,
  SUM(c.id_search IS NOT NULL) AS searches_with_click,
  ROUND(100 * SUM(c.id_search IS NOT NULL) / COUNT(*), 2) AS ctr_listing_pct
FROM ps_search_impression i
LEFT JOIN (
  SELECT DISTINCT id_search FROM ps_search_click
) c ON c.id_search = i.id_search
WHERE i.created_at >= NOW() - INTERVAL 7 DAY
  AND i.total_results > 0;

Ensuite seulement, calculez un CTR@k (qualité du ranking) : vous devez connaître le nombre d’impressions par position. Si vous n’avez loggé que le total de résultats, vous pouvez approximer les impressions à min(results_per_page, total_results) par page, mais c’est une approximation (pagination, lazy-load). Pour un CTR@10 fiable, loggez le nombre de produits réellement rendus dans le DOM (ou le tableau des IDs affichés). Sans ça, vous comparez des oranges et des pommes.

Enfin, si vous comparez deux moteurs (ex. core vs Meilisearch), fixez une règle simple : même période, même pages, mêmes filtres, et idéalement un split test (même part de trafic). Sinon vous “attribuez” à la recherche des variations qui viennent en fait de la saisonnalité, de promos, ou d’une rupture stock.

Attribuer la conversion à la recherche : last-touch, assistée, fenêtre temporelle

“Conversion recherche” est un terme ambigu. En e-commerce, on rencontre au moins trois définitions :

1) Directe : la commande a été passée après une recherche dans la même session (fenêtre 30 min / 24 h).
2) Last search touch : on attribue la commande à la dernière recherche avant l’achat.
3) Assistée : une recherche a eu lieu dans le parcours, mais l’achat peut venir d’une navigation catégorie, d’un favori, d’un retargeting, etc.

L’implémentation la plus simple côté PrestaShop consiste à capturer le last_search_id par id_guest (ou id_customer si connecté) et à le figer au moment de la validation de commande via hookActionValidateOrder. Vous stockez alors un mapping id_order -> id_search dans une table ps_search_order (ou dans order_note si vous aimez souffrir). Ça évite les jointures temporelles fragiles a posteriori.

Exemple de logique (pseudo) : quand vous loggez une impression, mettez à jour un petit cache “dernier search_id” (table, Redis, cookie). Au moment du paiement :

public function hookActionValidateOrder($params): void
{
    $idOrder = (int) $params['order']->id;
    $idGuest = (int) ($params['cart']->id_guest ?? 0);

    $lastSearchId = $this->searchLogger->getLastSearchIdForGuest($idGuest);
    if (!$lastSearchId) {
        return;
    }

    $this->searchLogger->linkOrderToSearch($idOrder, $lastSearchId);
}

Pour éviter les effets de bord, ajoutez une fenêtre : ne liez pas une commande à une recherche si la dernière recherche date d’il y a 10 jours (sinon vous surestimez). Même sur des cycles d’achat longs, vous pouvez distinguer :

  • “last search touch 30 min” (signal fort, proche de l’intention d’achat)
  • “last search touch 24 h” (signal plus large)
  • “assistée 7 jours” (utile pour produits chers, mais à interpréter)

Limites à assumer : cross-device, navigation privée, cookies supprimés, checkout externe, et clients qui se connectent tard. Si vous voulez du “propre”, vous devez combiner id_customer (quand dispo) + id_guest + une fenêtre de temps, et accepter qu’une partie restera “non attribuée”. C’est normal : l’objectif n’est pas la vérité absolue, mais un signal stable pour piloter.

Pour l’exploitation BI, une table de liaison dédiée reste la plus robuste (et évite de surcharger ps_orders) :

  • id_order (PK)
  • id_search (index)
  • attribution_model (ex. last_touch_30m, assist_24h)
  • created_at

Même si vous n’activez qu’un modèle au départ, le champ attribution_model vous évite de casser l’historique le jour où vous changez de définition.

Brancher GA4 / Matomo et industrialiser : événements standard + export API + alerting

Si vous voulez comparer votre recherche interne à d’autres canaux dans un outil d’analytics, alignez-vous sur les événements standard. Côté GA4, Google documente explicitement l’événement recommandé view_search_results (paramètre search_term). La documentation développeur e-commerce/événements est disponible : GA4 developer docs

Pour les clics, utilisez select_item avec le tableau items[] (au minimum item_id, item_name, et idéalement index pour la position). L’avantage : vous pouvez ensuite segmenter “searchterm -> selectitem -> addtocart -> purchase” sans réinventer un schéma. Si vous avez déjà une dataLayer e-commerce (GTM), ajoutez search_id en param custom pour recoller avec vos logs back (debuggable, non soumis aux adblockers côté serveur).

Côté Matomo, le suivi de recherche interne est une feature explicite (trackSiteSearch) et la documentation est claire : Matomo Site Search docs Matomo a l’avantage d’être plus “souverain” (hébergement on-prem) et moins opaque sur l’échantillonnage, mais ça ne vous dispense pas de votre logging serveur : les adblockers et les refus de consentement faussent les taux, surtout sur mobile.

Pour industrialiser, pensez “monitoring produit”, pas seulement “reporting marketing”. Quelques alertes simples donnent souvent un ROI immédiat :

  • zero_result_rate qui dépasse un seuil pendant X minutes (souvent corrélé à un index cassé, une langue non indexée, ou une régression de synonymes)
  • chute brutale de CTR listing (problème d’affichage, blocage JS, lenteur extrême)
  • hausse des temps de réponse recherche (si vous loggez un duration_ms côté serveur)

Enfin, pour exploiter ces données hors analytics (BI, monitoring, alerting), prévoyez un export. Le webservice PrestaShop peut aider si vous exposez une ressource dédiée (ou un endpoint module) — mais faites-le proprement : auth, ACL, pagination, rate limiting. Référence utile : Webservice PrestaShop : activer l’API et créer une clé d’accès. Et si vos calculs deviennent lourds, commencez par mesurer l’impact SQL (slow query log, profiling) plutôt que de blâmer “MySQL” : Requêtes MySQL lentes PrestaShop : activer slow query log et PrestaShop debug profiling : activer et analyser performances SQL.

Si votre recherche repose sur Elasticsearch/Meilisearch et que vous souhaitez corréler performance et UX (timeouts → zéro résultat → chute conversion), reliez aussi vos métriques applicatives au moteur : c’est souvent là que se trouvent les “vrais” incidents (index en retard, node saturé, erreurs 429/503), bien plus que dans le thème.


À lire aussi