feat(SitePerformance): Ajoute la collecte des métriques web vitales.
🐛 fix(caddy): Corrige la redirection du script Trustpilot.
📦 chore: Ajoute web-vitals comme dépendance et adapte package.json.
```
This commit is contained in:
Serreau Jovann
2026-01-27 23:36:11 +01:00
parent 63ee6b71c6
commit ff9ae0e8d4
9 changed files with 265 additions and 3 deletions

View File

@@ -20,7 +20,9 @@ intranet.ludikevent.fr, signature.ludikevent.fr, reservation.ludikevent.fr {
handle_path /utm_reserve.js { handle_path /utm_reserve.js {
redir https://tools-security.esy-web.dev/script.js redir https://tools-security.esy-web.dev/script.js
} }
handle_path /ts.js {
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js
}
# --- BLOC HEADER AVEC CSP --- # --- BLOC HEADER AVEC CSP ---
header { header {
X-Content-Type-Options "nosniff" X-Content-Type-Options "nosniff"

View File

@@ -2,6 +2,7 @@ import './reserve.scss';
import { UtmEvent, UtmAccount } from "./tools/UtmEvent.js"; import { UtmEvent, UtmAccount } from "./tools/UtmEvent.js";
import { CookieBanner } from "./tools/CookieBanner.js"; import { CookieBanner } from "./tools/CookieBanner.js";
import * as Turbo from "@hotwired/turbo"; import * as Turbo from "@hotwired/turbo";
import {onLCP, onINP, onCLS} from 'web-vitals';
// --- DÉTECTION BOT / PERFORMANCE --- // --- DÉTECTION BOT / PERFORMANCE ---
const isLighthouse = () => { const isLighthouse = () => {
@@ -53,7 +54,6 @@ const initImageLoader = () => {
const images = mainContainer.querySelectorAll('img:not(.loaded)'); const images = mainContainer.querySelectorAll('img:not(.loaded)');
console.log(images);
images.forEach(img => { images.forEach(img => {
// Sécurité : si l'image est déjà chargée (cache), on marque et on skip // Sécurité : si l'image est déjà chargée (cache), on marque et on skip
if (img.complete) { if (img.complete) {
@@ -195,8 +195,33 @@ const initRegisterLogic = () => {
updateSiretVisibility(); updateSiretVisibility();
}; };
const sendToAnalytics = ({ name, delta, id }) => {
// On ne veut pas polluer les stats avec les tests Lighthouse
if (isLighthouse()) return;
const body = JSON.stringify({
name, // 'LCP', 'INP', ou 'CLS'
value: delta, // La valeur de la mesure
id, // ID unique de la session de page (pour éviter les doublons)
path: window.location.pathname // Pour savoir quelle page est lente
});
const url = '/reservation/web-vitals';
// sendBeacon est idéal pour les stats car il ne bloque pas le thread principal
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, { body, method: 'POST', keepalive: true });
}
};
// --- INITIALISATION GLOBALE --- // --- INITIALISATION GLOBALE ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
initLoader(); initLoader();
initImageLoader(); initImageLoader();
// Enregistrement Custom Elements // Enregistrement Custom Elements

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260127223021 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE site_performance (id SERIAL NOT NULL, name VARCHAR(255) NOT NULL, value DOUBLE PRECISION NOT NULL, path VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN site_performance.created_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP TABLE site_performance');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260127223100 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE site_performance ADD metric_id VARCHAR(50) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE site_performance DROP metric_id');
}
}

View File

@@ -31,6 +31,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tom-select": "^2.4.3", "tom-select": "^2.4.3",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-favicon": "^1.0.8" "vite-plugin-favicon": "^1.0.8",
"web-vitals": "^5.1.0"
} }
} }

View File

@@ -7,6 +7,7 @@ use App\Entity\AccountResetPasswordRequest;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerTracking; use App\Entity\CustomerTracking;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\SitePerformance;
use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType; use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger; use App\Logger\AppLogger;
@@ -62,6 +63,35 @@ class ReserverController extends AbstractController
'products' => $products 'products' => $products
]); ]);
} }
#[Route('/reservation/web-vitals', name: 'reservation_web-vitals', methods: ['POST'])]
public function webVitals(Request $request, EntityManagerInterface $em): Response
{
$data = json_decode($request->getContent(), true);
if (!$data || !isset($data['name'], $data['value'])) {
return new Response('Invalid data', Response::HTTP_BAD_REQUEST);
}
// On vérifie si cet ID de métrique existe déjà pour éviter les doublons
// (web-vitals peut renvoyer plusieurs fois la même métrique si elle s'affine)
$existing = $em->getRepository(SitePerformance::class)->findOneBy(['metricId' => $data['id']]);
$perf = $existing ?? new SitePerformance();
$perf->setName($data['name']);
$perf->setValue((float)$data['value']);
$perf->setPath($data['path'] ?? '/');
$perf->setMetricId($data['id'] ?? null);
$perf->setUpdatedAt(new \DateTime());
if (!$existing) {
$em->persist($perf);
}
$em->flush();
return new Response('', Response::HTTP_NO_CONTENT);
}
#[Route('/reservation/umami', name: 'reservation_umami', methods: ['POST'])] #[Route('/reservation/umami', name: 'reservation_umami', methods: ['POST'])]
public function umami( public function umami(

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Entity;
use App\Repository\SitePerformanceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SitePerformanceRepository::class)]
class SitePerformance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column]
private ?float $value = null;
#[ORM\Column(length: 255)]
private ?string $path = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $metricId = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getValue(): ?float
{
return $this->value;
}
public function setValue(float $value): static
{
$this->value = $value;
return $this;
}
public function getPath(): ?string
{
return $this->path;
}
public function setPath(string $path): static
{
$this->path = $path;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getMetricId(): ?string
{
return $this->metricId;
}
public function setMetricId(?string $metricId): static
{
$this->metricId = $metricId;
return $this;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\SitePerformance;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SitePerformance>
*/
class SitePerformanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SitePerformance::class);
}
// /**
// * @return SitePerformance[] Returns an array of SitePerformance objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('s')
// ->andWhere('s.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('s.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?SitePerformance
// {
// return $this->createQueryBuilder('s')
// ->andWhere('s.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -75,6 +75,7 @@
<script data-host-url="https://tools-security.esy-web.dev" nonce="{{ csp_nonce('script') }}" defer src="/utm_reserve.js" data-website-id="bc640e0d-43fb-4c3a-bb17-1ac01cec9643"></script> <script data-host-url="https://tools-security.esy-web.dev" nonce="{{ csp_nonce('script') }}" defer src="/utm_reserve.js" data-website-id="bc640e0d-43fb-4c3a-bb17-1ac01cec9643"></script>
{% endif %} {% endif %}
<script nonce="{{ csp_nonce('script') }}" src="/ts.js"></script>
{{ vite_asset('reserve.js',{}) }} {{ vite_asset('reserve.js',{}) }}
{% block stylesheets %}{% endblock %} {% block stylesheets %}{% endblock %}
</head> </head>