Browse Source

Move visitor count server-side

imwald
Nuša Pukšič 3 months ago
parent
commit
9b01e08711
  1. 34
      assets/controllers/visit_analytics_controller.js
  2. 12
      src/Controller/Administration/VisitorAnalyticsController.php
  3. 33
      src/Controller/Api/VisitController.php
  4. 17
      src/Entity/Visit.php
  5. 70
      src/EventListener/VisitTrackingListener.php
  6. 45
      src/Repository/VisitRepository.php
  7. 40
      templates/admin/analytics.html.twig

34
assets/controllers/visit_analytics_controller.js

@ -1,34 +0,0 @@ @@ -1,34 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/**
* Simple analytics controller to record page visits
*/
export default class extends Controller {
static values = {
path: String
}
connect() {
// Record the visit when the controller connects
this.recordVisit();
}
recordVisit() {
// Get the current route path
const path = this.pathValue || window.location.pathname;
// Send visit data to API
fetch('/api/visit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
route: path
})
})
.catch(error => {
console.error('Error recording visit:', error);
});
}
}

12
src/Controller/Administration/VisitorAnalyticsController.php

@ -25,10 +25,22 @@ class VisitorAnalyticsController extends AbstractController @@ -25,10 +25,22 @@ class VisitorAnalyticsController extends AbstractController
$last24hCount = $visitRepository->countVisitsSince($last24h);
$last7dCount = $visitRepository->countVisitsSince($last7d);
// Unique session tracking
$uniqueVisitors24h = $visitRepository->countUniqueSessionsSince($last24h);
$uniqueVisitors7d = $visitRepository->countUniqueSessionsSince($last7d);
$totalUniqueVisitors = $visitRepository->countUniqueVisitors();
// Session-based visit breakdown
$sessionStats = $visitRepository->getVisitsBySession($last7d);
return $this->render('admin/analytics.html.twig', [
'visitStats' => $visitStats,
'last24hCount' => $last24hCount,
'last7dCount' => $last7dCount,
'uniqueVisitors24h' => $uniqueVisitors24h,
'uniqueVisitors7d' => $uniqueVisitors7d,
'totalUniqueVisitors' => $totalUniqueVisitors,
'sessionStats' => $sessionStats,
]);
}
}

33
src/Controller/Api/VisitController.php

@ -1,33 +0,0 @@ @@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Entity\Visit;
use App\Repository\VisitRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class VisitController extends AbstractController
{
#[Route('/api/visit', name: 'api_record_visit', methods: ['POST'])]
public function recordVisit(Request $request, VisitRepository $visitRepository): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['route']) || empty($data['route'])) {
return new JsonResponse(['error' => 'Route is required'], Response::HTTP_BAD_REQUEST);
}
$route = $data['route'];
$visit = new Visit($route);
$visitRepository->save($visit);
return new JsonResponse(['success' => true]);
}
}

17
src/Entity/Visit.php

@ -20,9 +20,13 @@ class Visit @@ -20,9 +20,13 @@ class Visit
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $visitedAt;
public function __construct(string $route)
#[ORM\Column(length: 255, nullable: true)]
private ?string $sessionId = null;
public function __construct(string $route, ?string $sessionId = null)
{
$this->route = $route;
$this->sessionId = $sessionId;
$this->visitedAt = new \DateTimeImmutable();
}
@ -46,4 +50,15 @@ class Visit @@ -46,4 +50,15 @@ class Visit
{
return $this->visitedAt;
}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function setSessionId(?string $sessionId): self
{
$this->sessionId = $sessionId;
return $this;
}
}

70
src/EventListener/VisitTrackingListener.php

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Visit;
use App\Repository\VisitRepository;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Bundle\SecurityBundle\Security;
#[AsEventListener(event: KernelEvents::REQUEST, method: 'onKernelRequest', priority: 0)]
class VisitTrackingListener
{
private const EXCLUDED_ROUTES = [
'/api/',
'/_profiler',
'/_wdt',
'/service-worker.js',
'/robots.txt',
'/assets/',
'/icons/',
];
public function __construct(
private readonly VisitRepository $visitRepository,
private readonly Security $security,
) {
}
public function onKernelRequest(RequestEvent $event): void
{
// Only track main requests, not sub-requests
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$route = $request->getPathInfo();
// Skip tracking for excluded routes (API, profiler, assets, etc.)
foreach (self::EXCLUDED_ROUTES as $excludedRoute) {
if (str_starts_with($route, $excludedRoute)) {
return;
}
}
// Get session ID if user is logged in
$sessionId = null;
if ($this->security->getUser()) {
// Start session if not already started
if (!$request->hasSession() || !$request->getSession()->isStarted()) {
$request->getSession()->start();
}
$sessionId = $request->getSession()->getId();
}
// Create and save the visit record
$visit = new Visit($route, $sessionId);
try {
$this->visitRepository->save($visit);
} catch (\Exception $e) {
// Silently fail to avoid breaking the request
// You could log this error if needed
}
}
}

45
src/Repository/VisitRepository.php

@ -48,4 +48,49 @@ class VisitRepository extends ServiceEntityRepository @@ -48,4 +48,49 @@ class VisitRepository extends ServiceEntityRepository
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Returns the count of unique sessions (logged-in users) since the given datetime.
*/
public function countUniqueSessionsSince(\DateTimeImmutable $since): int
{
$qb = $this->createQueryBuilder('v')
->select('COUNT(DISTINCT v.sessionId)')
->where('v.visitedAt >= :since')
->andWhere('v.sessionId IS NOT NULL')
->setParameter('since', $since, Types::DATETIME_IMMUTABLE);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Returns visits grouped by session ID with counts.
*/
public function getVisitsBySession(\DateTimeImmutable $since = null): array
{
$qb = $this->createQueryBuilder('v')
->select('v.sessionId, COUNT(v.id) as visitCount, MIN(v.visitedAt) as firstVisit, MAX(v.visitedAt) as lastVisit')
->where('v.sessionId IS NOT NULL')
->groupBy('v.sessionId')
->orderBy('visitCount', 'DESC');
if ($since) {
$qb->andWhere('v.visitedAt >= :since')
->setParameter('since', $since, Types::DATETIME_IMMUTABLE);
}
return $qb->getQuery()->getResult();
}
/**
* Returns unique visitor count (distinct session IDs).
*/
public function countUniqueVisitors(): int
{
$qb = $this->createQueryBuilder('v')
->select('COUNT(DISTINCT v.sessionId)')
->where('v.sessionId IS NOT NULL');
return (int) $qb->getQuery()->getSingleScalarResult();
}
}

40
templates/admin/analytics.html.twig

@ -14,6 +14,16 @@ @@ -14,6 +14,16 @@
</ul>
</div>
<div class="analytics-card">
<h2>Unique Logged-In Visitors</h2>
<ul class="analytics-stats">
<li><strong>Last 24 hours:</strong> {{ uniqueVisitors24h }}</li>
<li><strong>Last 7 days:</strong> {{ uniqueVisitors7d }}</li>
<li><strong>All time:</strong> {{ totalUniqueVisitors }}</li>
</ul>
<p class="analytics-note">Tracked by session ID for logged-in users only</p>
</div>
<div class="analytics-card">
<h2>Visit Count by Route</h2>
@ -39,8 +49,36 @@ @@ -39,8 +49,36 @@
{% endif %}
</div>
<div class="analytics-card">
<h2>User Sessions (Last 7 Days)</h2>
{% if sessionStats|length > 0 %}
<table class="analytics-table">
<thead>
<tr>
<th>Session ID</th>
<th class="text-right">Visits</th>
<th class="text-right">First Visit</th>
<th class="text-right">Last Visit</th>
</tr>
</thead>
<tbody>
{% for stat in sessionStats %}
<tr>
<td><code>{{ stat.sessionId|slice(0, 12) }}...</code></td>
<td class="text-right">{{ stat.visitCount }}</td>
<td class="text-right">{{ stat.firstVisit|date('M d, H:i') }}</td>
<td class="text-right">{{ stat.lastVisit|date('M d, H:i') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No logged-in visitor sessions recorded in the last 7 days.</p>
{% endif %}
</div>
<div class="analytics-info">
<p>This data shows the number of page visits per route. No personal user data is collected.</p>
<p>Visit tracking is now automated via event listener. Session IDs are captured for logged-in users to track unique visitors and user engagement patterns.</p>
</div>
</div>
{% endblock %}

Loading…
Cancel
Save