Skip to content

Architecture de monitoring

Architecture monitoring

Architecture globale de Zorni

Tableau des valeurs de tracking

Catégorie Événement Description Type d'événement Niveau
Événements de Visibilité search_impression L'annonce est apparue dans les résultats de recherche (même si l'utilisateur n'a pas cliqué). visibility_event info
publication_view Une annonce est affichée à l'écran. visibility_event info
Événements de Conversion preview_view L'utilisateur est arrivé à l'écran récapitulatif. conversion_event info
click_booking_button L'utilisateur a cliqué sur "Réserver" conversion_event info
add_to_favorites L'annonce a été enregistrée dans la liste de favoris. conversion_event info
Événements de marketing time_spent_on_publication Temps passé sur la page d'une annonce (ex: > 30 secondes). marketing_event info
bounce_event L'utilisateur quitte la page après seulement 2 secondes (indique souvent une photo de mauvaise qualité ou un prix trop élevé). marketing_event info
review_read L'utilisateur a lu les avis clients. marketing_event info
Événements technique api_error_frontend Une erreur 4XX ou 5XX reçue par le frontEnd. technical_event erreur

Exemple d'événement JSON à envoyer par le protal

Voici la structure de l'événement envoyé lorsqu'une annonce est consultée :

{
  "timestamp": "2026-01-04T15:00:00Z",
  "level": "info",
  "category": "visibility_event",
  "eventType": "publication_view",
  "publicationId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "tripPeference": "ABC1234",
  "establishmentId": "550e8400-e29b-41d4-a716-446655440000",
  "organizationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "visitorCountry": "FR"
}

Les graphes du dashboard

Le tableau de bord partenaire s'articule autour des graphiques suivants. Cette section détaille les spécifications techniques nécessaires à leur configuration.

les données envoyé par l'api monitoring

l'api monitoring renvoie le json suivant, pour plus d'information regarder la doc de l'api:

{
  "partnerSummary": {
    "organizationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "establishmentId": "550e8400-e29b-41d4-a716-446655440000",
    "publicationId": "111e8400-e29b-41d4-a716-446655441111",
    "tripPreference": "ABC1234",
    "period": "last_30_days",
    "generatedAt": "2026-01-05T18:30:00Z",
    "stats": {
      "funnel": [
        { "step": "Impressions", "value": 12500},
        { "step": "Vues", "value": 3200},
        { "step": "Pré-réservations", "value": 450},
        { "step": "Réservations", "value": 85}
      ],
      "engagement": {
        "bounceRate": 18.5,
        "avgTimeSpent": 45,
        "favoritesCount": 124,
        "reviewReadRate": 78
      },
      "evolution": [
        { "date": "2025-08", "visibility": 2100, "marketing": 1100, "conversion": 150 },
        { "date": "2025-09", "visibility": 2500, "marketing": 1300, "conversion": 180 },
        { "date": "2025-10", "visibility": 2800, "marketing": 1400, "conversion": 210 },
        { "date": "2025-11", "visibility": 3200, "marketing": 1800, "conversion": 240 },
        { "date": "2025-12", "visibility": 4500, "marketing": 2500, "conversion": 320 },
        { "date": "2026-01", "visibility": 5200, "marketing": 2900, "conversion": 410 }
      ],
      "geoDistribution": [
        { "country": "FR", "count": 1500, "percentage": 52 },
        { "country": "US", "count": 800, "percentage": 28 },
        { "country": "MA", "count": 600, "percentage": 20 }
      ]
    }
  }
}
  • Le Funnel de Conversion (funnel)

Chaque étape du funnel correspond à un comptage d'un eventType spécifique :

Impressions : Comptez tous les documents où eventType est search_impression.

Vues : Comptez les documents où eventType est publication_view.

Pré-réservations : Comptez les documents où eventType est preview_view.

Réservations : Comptez les documents où eventType est click_booking_button.

  • Métriques d'Engagement (engagement)

bounceRate (Taux de rebond) :

Logique : C'est le ratio de sessions où l'utilisateur n'a fait qu'un seul événement publication_view sans aucune autre interaction.

Calcul : (Sessions avec 1 seul event / Total des sessions) * 100.

avgTimeSpent : Calculez la moyenne de la valeur contenue dans le champ duration (que vous devez avoir dans vos logs time_spent_on_publication).

favoritesCount : Comptez le nombre total d'événements où eventType est add_to_favorites.

reviewReadRate :

Calcul : (Nombre d'événements "review_read" / Nombre de "publication_view") * 100.

  • Évolution Temporelle (evolution)

Ici, vous utilisez une agrégation Elasticsearch de type date_histogram (par mois ou par jour) avec des filtres internes :

visibility : Somme des événements dont la category est visibility_event.

marketing : Somme des événements dont la category est marketing_event.

conversion : Somme des événements dont la category est conversion_event.

  • Distribution Géographique (geoDistribution)

country : Utilisez une agrégation de type terms sur le champ visitorCountry.

count : Le nombre de documents trouvés pour ce code pays.

percentage: (count du pays / total des count de tous les pays) * 100.

nous allons utiliser le client officiel Elasticsearch Java Client (le successeur du RestHighLevelClient) pour construire cette requête de manière typée et robuste.

Voici comment implémenter le service Java qui va générer les données pour votre API Monitoring.

  • Dépendance Maven (pom.xml)

Assurez-vous d'avoir le client Java récent :

<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>8.x.x</version>
</dependency>
  • Le Service Java Spring Boot

Voici le code pour construire la requête d'agrégation et transformer la réponse en votre objet JSON :

@Service
public class MonitoringService {

    @Autowired
    private ElasticsearchClient esClient;

    public PartnerSummary getMonitoringStats(String orgId, String estId) throws IOException {

        // 1. Définition de la recherche avec agrégations
        SearchResponse<Void> response = esClient.search(s -> s
            .index("logs-tracking-*") // Votre index de logs
            .size(0) // On ne veut pas les documents, juste les stats
            .query(q -> q
                .bool(b -> b
                    .filter(f -> f.term(t -> t.field("organizationId").value(orgId)))
                    .filter(f -> f.term(t -> t.field("establishmentId").value(estId)))
                    .filter(f -> f.range(r -> r.field("timestamp").gte(JsonData.of("now-6M/M"))))
                )
            )
            .aggregations("evolution_temporelle", a -> a
                .dateHistogram(h -> h.field("timestamp").calendarInterval(CalendarInterval.Month).format("yyyy-MM"))
                .aggregations("visibility", sub -> sub.filter(f -> f.term(t -> t.field("category").value("visibility_event"))))
                .aggregations("marketing", sub -> sub.filter(f -> f.term(t -> t.field("category").value("marketing_event"))))
                .aggregations("conversion", sub -> sub.filter(f -> f.term(t -> t.field("category").value("conversion_event"))))
            )
            .aggregations("provenance_geo", a -> a
                .terms(t -> t.field("visitorCountry.keyword").size(10))
            ),
            Void.class
        );

        // 2. Transformation du résultat pour votre JSON
        return mapToPartnerSummary(response, orgId, estId);
    }

    private PartnerSummary mapToPartnerSummary(SearchResponse<Void> response, String orgId, String estId) {
        List<EvolutionData> evolution = new ArrayList<>();

        // Extraction de l'histogramme
        response.aggregations().get("evolution_temporelle").dateHistogram().buckets().array().forEach(bucket -> {
            evolution.add(new EvolutionData(
                bucket.keyAsString(),
                bucket.aggregations().get("visibility").filter().docCount(),
                bucket.aggregations().get("marketing").filter().docCount(),
                bucket.aggregations().get("conversion").filter().docCount()
            ));
        });

        // Extraction de la géo-distribution
        List<GeoData> geoDist = response.aggregations().get("provenance_geo").sterms().buckets().array().stream()
            .map(b -> new GeoData(b.key().stringValue(), b.docCount()))
            .collect(Collectors.toList());

        // Construction de l'objet final (simplifié pour l'exemple)
        return new PartnerSummary(orgId, estId, evolution, geoDist);
    }
}

Graphe de tunele de conversion (Funnel Chart)

C'est le graphique maître du dashboard. Il permet au partenaire de comprendre où il perd des clients potentiels.

Architecture monitoring

Graphe de tunele de conversion

  • Données à récupérer : Le compte (count) des événements groupés par eventType.
  • Calcul : On filtre sur organizationId ou establishmentId.
  • Visuel : Un entonnoir descendant.
Étape Événement cible Formule de calcul
1. Portée search_impression Somme totale des impressions.
2. Intérêt publication_view (Total vues / Total impressions) × 100 = CTR
3. Intention preview_view (Total previews / Total vues) × 100
4. Action click_booking_button (Total clics / Total vues) × 100 = Taux de conversion

Exemple d'implémentation (Recharts)

Voici comment mettre en place le graphique en entonnoir en utilisant la bibliothèque Recharts :

  1. Installation des dépendances

    npm install recharts # Pour la flexibilité totale
    # OU
    npm install @tremor/react # Pour des composants dashboard tout prêts
    

  2. Implémentation technique Recharts propose un composant FunnelChart. Il est crucial pour visualiser la déperdition entre l'impression et la réservation.

import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts';

const funnelData = [
  { value: 12500, name: 'Impressions', fill: '#8884d8' },
  { value: 3200, name: 'Vues', fill: '#83a6ed' },
  { value: 450, name: 'Aperçus', fill: '#8dd1e1' },
  { value: 85, name: 'Réservations', fill: '#82ca9d' },
];

// Dans votre composant :
<ResponsiveContainer width="100%" height={300}>
  <FunnelChart>
    <Tooltip />
    <Funnel dataKey="value" data={funnelData} isAnimationActive>
      <LabelList position="right" fill="#000" stroke="none" dataKey="name" />
    </Funnel>
  </FunnelChart>
</ResponsiveContainer>

Le Graphique "Avis Clients Consultés" (Donut Chart)

Ce graphique correspond à votre deuxième image. Il montre le pourcentage d'utilisateurs engagés ayant lu les avis.

Graphique

Graphique "Avis Clients Consultés

import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';

const AvisConsultes = ({ pourcentage = 78 }) => {
  const data = [
    { name: 'Engagés', value: pourcentage },
    { name: 'Autres', value: 100 - pourcentage },
  ];

  // Couleurs basées sur votre visuel
  const COLORS = ['#1e40af', '#3b82f6']; 

  return (
    <div className="bg-slate-50 p-6 rounded-xl shadow-sm border border-slate-100 max-w-sm text-center">
      <h3 className="text-lg font-bold text-slate-800 mb-4">Avis Clients Consultés</h3>

      <div className="relative h-64 w-full">
        <ResponsiveContainer width="100%" height="100%">
          <PieChart>
            <Pie
              data={data}
              innerRadius={70}
              outerRadius={90}
              paddingAngle={0}
              dataKey="value"
              startAngle={90}
              endAngle={450}
            >
              {data.map((entry, index) => (
                <Cell key={`cell-${index}`} fill={COLORS[index]} stroke="none" />
              ))}
            </Pie>
          </PieChart>
        </ResponsiveContainer>

        {/* Texte central */}
        <div className="absolute inset-0 flex flex-col items-center justify-center">
          <span className="text-4xl font-extrabold text-slate-800">{pourcentage}%</span>
          <span className="text-sm text-slate-500">Clients Engagés</span>
        </div>
      </div>

      <p className="mt-4 text-sm text-green-600 font-medium">
        Félicitations ! Les visiteurs lisent vos retours.
      </p>
    </div>
  );
};

Le Graphique "Qualité de l'Annonce" (Gauge Chart)

Ce graphique correspond à l'affichage du Taux de Rebond.

Graphique Qualité Annonce

Graphique "Qualité de l'Annonce"

import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';

const QualiteAnnonce = ({ tauxRebond = 18.5 }) => {
  // Configuration de la jauge (Vert -> Orange -> Rouge)
  const data = [
    { value: 33, color: '#22c55e' }, // Vert
    { value: 33, color: '#f97316' }, // Orange
    { value: 34, color: '#ef4444' }, // Rouge
  ];

  return (
    <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 max-w-sm text-center">
      <h3 className="text-lg font-bold text-slate-800 mb-2">Qualité de l'Annonce</h3>

      <div className="relative h-48 w-full">
        <ResponsiveContainer width="100%" height="100%">
          <PieChart>
            <Pie
              data={data}
              cx="50%"
              cy="100%"
              startAngle={180}
              endAngle={0}
              innerRadius={60}
              outerRadius={80}
              paddingAngle={0}
              dataKey="value"
            >
              {data.map((entry, index) => (
                <Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
              ))}
            </Pie>
          </PieChart>
        </ResponsiveContainer>

        {/* Valeur centrale */}
        <div className="absolute inset-0 flex flex-col items-center justify-end pb-4">
          <span className="text-4xl font-extrabold text-slate-800">{tauxRebond}%</span>
          <span className="text-sm text-slate-500 font-medium uppercase tracking-wider">Taux de Rebond</span>
        </div>
      </div>

      <div className="mt-8 border-t pt-4">
        <span className="text-slate-400 font-bold block mb-1">Engagement</span>
        <p className="text-sm text-green-600 font-medium">
          <span className="font-bold">Excellent !</span> Les visiteurs restent sur votre annonce.
        </p>
      </div>
    </div>
  );
};

Explications pour l'intégration :

  • Données dynamiques : Vous pouvez passer les valeurs 78% ou 18.5% via des props que vous récupérez depuis votre API de Monitoring (que vous interrogerez dans un useEffect).

  • Formatage : Les composants sont enveloppés dans ResponsiveContainer pour s'adapter automatiquement à la taille de vos colonnes dans votre dashboard React.

  • Style : Les couleurs CSS (#1e40af, #22c55e, etc.) ont été choisies pour correspondre exactement aux tons professionnels de vos captures d'écran.

Le Graphique temps Passé sur l'Annonce

Le Graphique Temps Passé sur l'AnnonceTemps Passé sur l'Annonce s'agit d'une Barre de Progression Segmentée avec un curseur (pointer). C'est un excellent moyen de visualiser une moyenne par rapport à des seuils de performance.

Graphique Temps Passé sur Annonce

Graphique Temps Passé sur Annonce

Voici comment l'implémenter de manière élégante dans votre projet React :

Structure du composant TempsSurAnnonce Nous allons utiliser une approche "Custom CSS" avec Tailwind pour créer la barre tricolore et placer le curseur dynamiquement.

import React from 'react';

const TempsSurAnnonce = ({ secondes = 45 }) => {
  // Calcul de la position du curseur en pourcentage (max 60s pour l'échelle)
  const position = Math.min((secondes / 60) * 100, 100);

  // Déterminer le message en fonction du temps
  const getMessage = () => {
    if (secondes > 30) return "Excellent ! Votre contenu retient leur attention.";
    if (secondes >= 10) return "Bon intérêt, mais peut être amélioré.";
    return "Attention, le temps de lecture est trop faible.";
  };

  return (
    <div className="bg-slate-50 p-6 rounded-xl shadow-sm border border-slate-100 max-w-lg">
      <h3 className="text-lg font-bold text-slate-800 mb-1">Temps Passé sur l'Annonce</h3>
      <p className="text-sm text-slate-500 mb-8">Durée Moyenne Passée</p>

      <div className="relative pt-6 pb-2">
        {/* Curseur (Aiguille) */}
        <div 
          className="absolute top-0 flex flex-col items-center transition-all duration-1000 ease-out"
          style={{ left: `${position}%`, transform: 'translateX(-50%)' }}
        >
          <div className="w-0.5 h-8 bg-slate-800"></div>
          <div className="w-2 h-2 rounded-full bg-slate-800 -mt-1"></div>
          <span className="text-sm font-bold text-slate-800 mt-1 whitespace-nowrap">
            {secondes} secondes
          </span>
        </div>

        {/* Barre segmentée */}
        <div className="flex h-8 w-full rounded-sm overflow-hidden mt-12">
          {/* Faible < 10s */}
          <div className="w-[16.6%] bg-red-500 h-full border-r border-white/20"></div>
          {/* Intérêt 10-30s */}
          <div className="w-[33.4%] bg-orange-500 h-full border-r border-white/20"></div>
          {/* Élevé > 30s */}
          <div className="w-[50%] bg-green-500 h-full"></div>
        </div>

        {/* Légendes sous la barre */}
        <div className="flex justify-between mt-2 text-[10px] font-bold uppercase tracking-tighter">
          <span className="text-red-600 w-[16.6%]">&lt; 10s (Faible)</span>
          <span className="text-orange-600 w-[33.4%] text-center">10-30s (Intérêt)</span>
          <span className="text-green-600 w-[50%] text-right">&gt; 30s (Élevé)</span>
        </div>
      </div>

      <p className="mt-8 text-center text-sm text-slate-600 font-medium italic">
        {getMessage()}
      </p>
    </div>
  );
};

export default TempsSurAnnonce;

Le Graphique Activité Semestrielle

Le graphique d'Activité Semestrielle (Aires empilées) dans React, nous allons utiliser Recharts.

Graphique Activité Semestrielle

Graphique Activité Semestrielle

import React from 'react';
import { 
  AreaChart, Area, XAxis, YAxis, CartesianGrid, 
  Tooltip, ResponsiveContainer, Legend 
} from 'recharts';

const ActiviteSemestrielle = ({ data }) => {
  return (
    <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 w-full h-[400px]">
      <h3 className="text-lg font-bold text-slate-800 mb-6">Activité Semestrielle</h3>

      <ResponsiveContainer width="100%" height="100%">
        <AreaChart
          data={data}
          margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
        >
          <defs>
            {/* Dégradés pour un rendu plus moderne */}
            <linearGradient id="colorVis" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8}/>
              <stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
            </linearGradient>
          </defs>

          <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
          <XAxis 
            dataKey="date" 
            axisLine={false} 
            tickLine={false} 
            tick={{fill: '#94a3b8', fontSize: 12}}
            dy={10}
          />
          <YAxis 
            axisLine={false} 
            tickLine={false} 
            tick={{fill: '#94a3b8', fontSize: 12}}
          />
          <Tooltip 
            contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
          />
          <Legend verticalAlign="top" height={36}/>

          {/* Couche Marketing (Base) */}
          <Area
            type="monotone"
            dataKey="marketing"
            stackId="1"
            stroke="#94a3b8"
            fill="#cbd5e1"
            name="Événements Marketing"
          />

          {/* Couche Visibilité (Milieu) */}
          <Area
            type="monotone"
            dataKey="visibility"
            stackId="1"
            stroke="#3b82f6"
            fill="url(#colorVis)"
            name="Visibilité de l'Annonce"
          />

          {/* Couche Conversion (Sommet) */}
          <Area
            type="monotone"
            dataKey="conversion"
            stackId="1"
            stroke="#1e40af"
            fill="#1e3a8a"
            name="Conversions"
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
};

export default ActiviteSemestrielle;

Le graphique de Provenance Internationale (carte du monde interactive)

Pour intégrer le graphique de Provenance Internationale (carte du monde interactive), la solution la plus robuste et la plus simple pour React est d'utiliser react-simple-maps.

le graphique de Provenance Internationale

le graphique de Provenance Internationale

Voici le code pour transformer vos données de geoDistribution en une carte visuelle.

npm install react-simple-maps d3-scale
import React from "react";
import {
  ComposableMap,
  Geographies,
  Geography,
  Sphere,
  Graticule
} from "react-simple-maps";
import { scaleLinear } from "d3-scale";

// URL pour les données géographiques (pays)
const geoUrl = "https://raw.githubusercontent.com/lotusms/world-map-data/main/world.json";

const ProvenanceInternationale = ({ data }) => {
  // data est attendu sous la forme : [{ country: "FR", count: 1500 }, ...]

  // Création d'une échelle de couleur pour l'intensité du bleu
  const colorScale = scaleLinear()
    .domain([0, Math.max(...data.map((d) => d.count))])
    .range(["#cbd5e1", "#1e3a8a"]); // Du gris clair au bleu foncé

  return (
    <div className="bg-white p-6 rounded-xl shadow-md border border-slate-100 w-full h-[500px]">
      <div className="mb-4">
        <h3 className="text-xl font-bold text-slate-800">Provenance Internationale</h3>
        <p className="text-sm text-slate-500">Évolution des Événements (7 jours)</p>
      </div>

      <div className="w-full h-full">
        <ComposableMap
          projectionConfig={{ rotate: [-10, 0, 0], scale: 147 }}
          width={800}
          height={400}
        >
          <Sphere stroke="#f1f5f9" strokeWidth={0.5} />
          <Graticule stroke="#f1f5f9" strokeWidth={0.5} />
          <Geographies geography={geoUrl}>
            {({ geographies }) =>
              geographies.map((geo) => {
                // On cherche si le pays actuel est présent dans notre JSON d'API
                const d = data.find((s) => s.country === geo.properties.ISO_A2);
                return (
                  <Geography
                    key={geo.rsmKey}
                    geography={geo}
                    fill={d ? colorScale(d.count) : "#f1f5f9"}
                    stroke="#ffffff"
                    strokeWidth={0.5}
                    style={{
                      default: { outline: "none" },
                      hover: { fill: "#3b82f6", outline: "none" }, // Bleu au survol
                      pressed: { outline: "none" }
                    }}
                  />
                );
              })
            }
          </Geographies>
        </ComposableMap>
      </div>

      {/* Légende rapide */}
      <div className="flex justify-end items-center gap-2 mt-2">
        <span className="text-xs text-slate-400">0 Vues</span>
        <div className="w-32 h-2 rounded-full bg-gradient-to-r from-slate-200 to-blue-900"></div>
        <span className="text-xs text-slate-400">Maximum</span>
      </div>
    </div>
  );
};

export default ProvenanceInternationale;