Architecture de 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.
Graphe de tunele de conversion
- Données à récupérer : Le compte (
count) des événements groupés pareventType. - Calcul : On filtre sur
organizationIdouestablishmentId. - 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 :
-
Installation des dépendances
-
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 "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é 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
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%]">< 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">> 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
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
Voici le code pour transformer vos données de geoDistribution en une carte visuelle.
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;