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
This commit is contained in:
11 files changed
+298
-26
No files matched your search
@@ -42,7 +42,7 @@ class AnalyticsServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
$this->registerMiddleware();
|
||||
$this->warnIfSiteKeyMissing();
|
||||
$this->warnIfConfigMissing();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,16 +57,20 @@ class AnalyticsServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning if site_key is not configured.
|
||||
* Log a warning if site_key or ingest_secret is not configured.
|
||||
*/
|
||||
private function warnIfSiteKeyMissing(): void
|
||||
private function warnIfConfigMissing(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
if ($this->app->runningInConsole() || ! config('mattitja-analytics.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty(config('mattitja-analytics.site_key'))) {
|
||||
Log::warning('Mattitja Analytics: site_key is not configured. Add MATTITJA_ANALYTICS_SITE_KEY to your .env file.');
|
||||
}
|
||||
|
||||
if (empty(config('mattitja-analytics.ingest_secret'))) {
|
||||
Log::warning('Mattitja Analytics: ingest_secret is not configured. Add MATTITJA_ANALYTICS_INGEST_SECRET to your .env file.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class InstallCommand extends Command
|
||||
|
||||
$this->components->info('Mattitja Analytics installed successfully!');
|
||||
$this->components->warn('Add your site key to .env: MATTITJA_ANALYTICS_SITE_KEY=your-key');
|
||||
$this->components->warn('Add your ingest secret to .env: MATTITJA_ANALYTICS_INGEST_SECRET=your-secret');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
@@ -33,9 +34,16 @@ class InstallCommand extends Command
|
||||
}
|
||||
|
||||
$envContent = file_get_contents($envPath);
|
||||
$additions = '';
|
||||
|
||||
if (! str_contains($envContent, 'MATTITJA_ANALYTICS_SITE_KEY')) {
|
||||
file_put_contents($envPath, $envContent."\nMATTITJA_ANALYTICS_SITE_KEY=\n");
|
||||
foreach (['MATTITJA_ANALYTICS_SITE_KEY', 'MATTITJA_ANALYTICS_INGEST_SECRET'] as $key) {
|
||||
if (! str_contains($envContent, $key)) {
|
||||
$additions .= "{$key}=\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($additions !== '') {
|
||||
file_put_contents($envPath, $envContent."\n".$additions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ readonly class PageviewData
|
||||
public string $ip,
|
||||
public string $userAgent,
|
||||
public ?string $referrer = null,
|
||||
public ?string $acceptLanguage = null,
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
|
||||
@@ -36,7 +36,7 @@ class AnalyticsMiddleware
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if ($this->shouldCaptureAnalytics($request)) {
|
||||
if ($this->shouldCaptureAnalytics($request, $response)) {
|
||||
$this->pendingData = $this->buildAnalyticsData($request);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,12 @@ class AnalyticsMiddleware
|
||||
/**
|
||||
* Determine if analytics should be captured for this request.
|
||||
*/
|
||||
protected function shouldCaptureAnalytics(Request $request): bool
|
||||
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)
|
||||
@@ -82,6 +84,34 @@ class AnalyticsMiddleware
|
||||
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.
|
||||
*/
|
||||
@@ -156,6 +186,7 @@ class AnalyticsMiddleware
|
||||
ip: $request->ip(),
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
referrer: $request->header('referer'),
|
||||
acceptLanguage: $request->header('Accept-Language'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,7 +197,7 @@ class AnalyticsMiddleware
|
||||
{
|
||||
try {
|
||||
app(AnalyticsClient::class)->send($data);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send analytics data', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -11,30 +11,87 @@ class AnalyticsClient
|
||||
/**
|
||||
* The Mattitja Analytics API endpoint.
|
||||
*/
|
||||
private const string ENDPOINT = 'https://analytics.mattitja.cloud/api/send';
|
||||
|
||||
/**
|
||||
* HTTP request timeout in seconds.
|
||||
*/
|
||||
private const int TIMEOUT = 5;
|
||||
private const string ENDPOINT = 'https://analytics.mattitja.se/api/send';
|
||||
|
||||
/**
|
||||
* Send analytics data to the Mattitja Analytics API.
|
||||
*/
|
||||
public function send(PageviewData $data): void
|
||||
{
|
||||
if (! $this->validateData($data)) {
|
||||
if (! $this->validateData($data) || $this->isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(self::TIMEOUT)
|
||||
$response = Http::withOptions([
|
||||
'timeout' => (float) config('mattitja-analytics.timeout', 1),
|
||||
'connect_timeout' => (float) config('mattitja-analytics.connect_timeout', 0.5),
|
||||
])
|
||||
->acceptJson()
|
||||
->withToken((string) config('mattitja-analytics.ingest_secret'))
|
||||
->withHeaders(array_filter([
|
||||
'Accept-Language' => $data->acceptLanguage,
|
||||
]))
|
||||
->post(self::ENDPOINT, $data->toArray());
|
||||
} catch (\Exception) {
|
||||
// Fail silently to not break user experience
|
||||
|
||||
if ($response->serverError()) {
|
||||
$this->pause();
|
||||
} elseif ($response->successful()) {
|
||||
$this->resume();
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('Mattitja Analytics: API rejected pageview', [
|
||||
'status' => $response->status(),
|
||||
'body' => mb_substr($response->body(), 0, 500),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Never break the host application; back off and try again later.
|
||||
$this->pause();
|
||||
|
||||
Log::warning('Mattitja Analytics: failed to reach API, pausing', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether sending is paused after a recent failure.
|
||||
*/
|
||||
public function isPaused(): bool
|
||||
{
|
||||
$pausedAt = @filemtime($this->pauseFile());
|
||||
|
||||
return $pausedAt !== false
|
||||
&& $pausedAt > time() - (int) config('mattitja-analytics.pause_on_failure', 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause sending (circuit breaker) by touching a marker file.
|
||||
*/
|
||||
protected function pause(): void
|
||||
{
|
||||
if ((int) config('mattitja-analytics.pause_on_failure', 60) > 0) {
|
||||
@touch($this->pauseFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pause marker after a successful request.
|
||||
*/
|
||||
protected function resume(): void
|
||||
{
|
||||
if (is_file($this->pauseFile())) {
|
||||
@unlink($this->pauseFile());
|
||||
}
|
||||
}
|
||||
|
||||
protected function pauseFile(): string
|
||||
{
|
||||
return storage_path('framework/mattitja-analytics.paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all required fields are present and not empty.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user