- 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
207 lines
5.4 KiB
PHP
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(),
|
|
]);
|
|
}
|
|
}
|
|
}
|