Files
mattitja-analytics/src/Middleware/AnalyticsMiddleware.php
T
mathiaspalmqvist 19f632d652 fix: send pageviews to the analytics API correctly
- Use the correct endpoint (analytics.mattitja.se instead of the
  non-existent .cloud domain)
- Authenticate with the site ingest secret as a Bearer token so the API
  trusts the forwarded visitor IP and user agent
- Forward the visitor Accept-Language header (required by bot detection)
- Only track successful GET requests rendering HTML
- Short timeouts and a file-based circuit breaker that pauses sending
  after connection failures or 5xx responses, so no queue, worker or
  cache is needed and a down API never slows the host site
- Log rejected requests instead of failing silently
2026-11-12 09:08:27 +01:00

207 lines
5.4 KiB
PHP

<?php
namespace Mattitja\Analytics\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Mattitja\Analytics\Data\PageviewData;
use Mattitja\Analytics\Services\AnalyticsClient;
use Symfony\Component\HttpFoundation\Response;
class AnalyticsMiddleware
{
/**
* Common bot user agent patterns for detection.
*/
private const array BOT_PATTERNS = [
'bot',
'crawler',
'spider',
'scraper',
'curl',
'wget',
'googlebot',
'bingbot',
'facebookexternalhit',
'twitterbot',
];
private ?PageviewData $pendingData = null;
/**
* Handle an incoming request and capture analytics data.
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($this->shouldCaptureAnalytics($request, $response)) {
$this->pendingData = $this->buildAnalyticsData($request);
}
return $response;
}
/**
* Handle tasks after the response has been sent to the browser.
*/
public function terminate(Request $request, Response $response): void
{
if ($this->pendingData !== null) {
$this->sendAnalytics($this->pendingData);
}
}
/**
* Determine if analytics should be captured for this request.
*/
protected function shouldCaptureAnalytics(Request $request, Response $response): bool
{
return $this->isAnalyticsEnabled()
&& $this->hasSiteKey()
&& $this->hasIngestSecret()
&& $this->isPageview($request, $response)
&& ! $this->isIgnoredPath($request)
&& ! $this->isIgnoredIp($request)
&& ! $this->shouldIgnoreBot($request)
&& ! $this->respectsDoNotTrack($request);
}
/**
* Check if analytics tracking is enabled.
*/
private function isAnalyticsEnabled(): bool
{
return config('mattitja-analytics.enabled', true);
}
/**
* Check if site key is configured.
*/
private function hasSiteKey(): bool
{
return ! empty(config('mattitja-analytics.site_key'));
}
/**
* Check if the ingest secret is configured. Without it the API ignores the
* forwarded visitor IP/UA and rejects the server-to-server request.
*/
private function hasIngestSecret(): bool
{
return ! empty(config('mattitja-analytics.ingest_secret'));
}
/**
* Only successful GET requests rendering HTML count as pageviews
* (skips form posts, redirects, JSON/Livewire calls and assets).
*/
protected function isPageview(Request $request, Response $response): bool
{
if (! $request->isMethod('GET') || $request->expectsJson()) {
return false;
}
if (! $response->isSuccessful()) {
return false;
}
$contentType = (string) $response->headers->get('Content-Type', 'text/html');
return str_contains($contentType, 'text/html');
}
/**
* Check if bot traffic should be ignored.
*/
private function shouldIgnoreBot(Request $request): bool
{
return $this->isBot($request) && ! config('mattitja-analytics.capture_bots', false);
}
/**
* Check if the request path should be ignored.
*/
protected function isIgnoredPath(Request $request): bool
{
$ignoredPaths = config('mattitja-analytics.ignore_paths', []);
$path = $request->path();
foreach ($ignoredPaths as $pattern) {
if (fnmatch($pattern, $path)) {
return true;
}
}
return false;
}
/**
* Check if the request IP should be ignored.
*/
protected function isIgnoredIp(Request $request): bool
{
$ignoredIps = config('mattitja-analytics.ignore_ips', []);
return in_array($request->ip(), $ignoredIps, true);
}
/**
* Detect if the request is from a bot.
*/
protected function isBot(Request $request): bool
{
$userAgent = $request->userAgent() ?? '';
foreach (self::BOT_PATTERNS as $pattern) {
if (stripos($userAgent, $pattern) !== false) {
return true;
}
}
return false;
}
/**
* Check if the request respects Do Not Track header.
*/
protected function respectsDoNotTrack(Request $request): bool
{
if (! config('mattitja-analytics.respect_dnt', true)) {
return false;
}
return $request->header('DNT') === '1';
}
/**
* Build analytics data payload from request.
*/
protected function buildAnalyticsData(Request $request): PageviewData
{
return new PageviewData(
siteKey: config('mattitja-analytics.site_key'),
url: $request->fullUrl(),
ip: $request->ip(),
userAgent: $request->userAgent() ?? '',
referrer: $request->header('referer'),
acceptLanguage: $request->header('Accept-Language'),
);
}
/**
* Send analytics data to the API.
*/
protected function sendAnalytics(PageviewData $data): void
{
try {
app(AnalyticsClient::class)->send($data);
} catch (\Throwable $e) {
Log::error('Failed to send analytics data', [
'error' => $e->getMessage(),
]);
}
}
}