diff --git a/README.md b/README.md index fafee0e..d53418d 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,16 @@ Publish the configuration file (optional): php artisan vendor:publish --tag=mattitja-analytics-config ``` -Add your site key to your `.env` file: +Add your site key and ingest secret to your `.env` file: ```env MATTITJA_ANALYTICS_SITE_KEY=your-site-key +MATTITJA_ANALYTICS_INGEST_SECRET=your-ingest-secret MATTITJA_ANALYTICS_ENABLED=true ``` +Both values are found under **Site → Install** in Mattitja Analytics. Click "Generate secret" to create the ingest secret. Without it the API ignores the forwarded visitor IP and user agent and rejects the request, so nothing is tracked. + ## Usage The package automatically registers middleware for the `web` group and starts collecting analytics data immediately after installation and configuration. @@ -42,6 +45,10 @@ Route::middleware(['mattitja-analytics'])->group(function () { ### Basic Settings - `MATTITJA_ANALYTICS_SITE_KEY` - Your site key from Mattitja Analytics (required) +- `MATTITJA_ANALYTICS_INGEST_SECRET` - Your site's ingest secret, sent as a Bearer token (required) +- `MATTITJA_ANALYTICS_TIMEOUT` - HTTP timeout in seconds (default: 1) +- `MATTITJA_ANALYTICS_CONNECT_TIMEOUT` - Connect timeout in seconds (default: 0.5) +- `MATTITJA_ANALYTICS_PAUSE_ON_FAILURE` - Seconds to stop sending after the API is unreachable or returns 5xx (default: 60, `0` disables) - `MATTITJA_ANALYTICS_ENABLED` - Enable/disable tracking (default: true) - `MATTITJA_ANALYTICS_BOTS` - Track bots (default: false) @@ -53,7 +60,7 @@ You can configure paths and IP addresses to ignore in the config file. ## Data Sent -The package sends the following data to `https://analytics.mattitja.cloud/api/send`: +The package sends the following data to `https://analytics.mattitja.se/api/send`: - `k` - Site key - `u` - Full URL of the visited page @@ -61,10 +68,15 @@ The package sends the following data to `https://analytics.mattitja.cloud/api/se - `ip` - Visitor's complete IP address - `ua` - User agent string +The request is authenticated with `Authorization: Bearer `, and the visitor's `Accept-Language` header is forwarded. + +Only successful `GET` requests that render HTML count as pageviews. Form posts, redirects, JSON responses and requests that expect JSON are skipped. ## Security -- Data is sent synchronously directly from middleware +- Data is sent from the middleware's `terminate()` step, after the response has been sent to the browser - IP addresses are NOT anonymized (needed for geolocation) +- No queue, worker or cache is required. If the API is down, sending pauses for `pause_on_failure` seconds, tracked with a marker file in `storage/framework`, so visitors and PHP workers are never held up +- All errors are caught and logged, so the API being down never breaks your site - Bot traffic is filtered out by default - Do Not Track header is respected by default diff --git a/config/mattitja-analytics.php b/config/mattitja-analytics.php index 743b649..fbfb6a2 100644 --- a/config/mattitja-analytics.php +++ b/config/mattitja-analytics.php @@ -12,6 +12,19 @@ return [ 'site_key' => env('MATTITJA_ANALYTICS_SITE_KEY'), + // Site ingest secret (Site -> Install in Mattitja Analytics). Sent as a + // Bearer token so the API trusts the visitor IP and user agent we forward. + 'ingest_secret' => env('MATTITJA_ANALYTICS_INGEST_SECRET'), + + // HTTP timeouts in seconds. Sending runs after the response is sent, but + // still occupies the PHP worker, so keep these short. + 'timeout' => env('MATTITJA_ANALYTICS_TIMEOUT', 1), + 'connect_timeout' => env('MATTITJA_ANALYTICS_CONNECT_TIMEOUT', 0.5), + + // When the API is unreachable or returns 5xx, pause sending for this many + // seconds (tracked with a file in storage/framework, no cache needed). + 'pause_on_failure' => env('MATTITJA_ANALYTICS_PAUSE_ON_FAILURE', 60), + /* |-------------------------------------------------------------------------- | Capture Configuration diff --git a/src/AnalyticsServiceProvider.php b/src/AnalyticsServiceProvider.php index 6ccecc9..d30fff1 100644 --- a/src/AnalyticsServiceProvider.php +++ b/src/AnalyticsServiceProvider.php @@ -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.'); + } } } diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index f2688fd..26ed158 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -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); } } } diff --git a/src/Data/PageviewData.php b/src/Data/PageviewData.php index 414ec7f..0bdce23 100644 --- a/src/Data/PageviewData.php +++ b/src/Data/PageviewData.php @@ -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 diff --git a/src/Middleware/AnalyticsMiddleware.php b/src/Middleware/AnalyticsMiddleware.php index 4725788..970760b 100644 --- a/src/Middleware/AnalyticsMiddleware.php +++ b/src/Middleware/AnalyticsMiddleware.php @@ -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(), ]); diff --git a/src/Services/AnalyticsClient.php b/src/Services/AnalyticsClient.php index 1bf3e63..de229c7 100644 --- a/src/Services/AnalyticsClient.php +++ b/src/Services/AnalyticsClient.php @@ -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. */ diff --git a/tests/Feature/AnalyticsMiddlewareTest.php b/tests/Feature/AnalyticsMiddlewareTest.php index 4e6668a..caf9328 100644 --- a/tests/Feature/AnalyticsMiddlewareTest.php +++ b/tests/Feature/AnalyticsMiddlewareTest.php @@ -48,7 +48,7 @@ it('captures analytics data for web requests', function () { Http::assertSent(function ($request) { $data = $request->data(); - return $request->url() === 'https://analytics.mattitja.cloud/api/send' && + return $request->url() === 'https://analytics.mattitja.se/api/send' && $data['k'] === 'testsiteky' && $data['u'] === 'https://example.com/test' && $data['r'] === 'https://google.com' && @@ -140,3 +140,50 @@ it('handles empty user agent gracefully', function () { return array_key_exists('ua', $data) && $data['ua'] === ''; }); }); + +it('does not capture analytics without an ingest secret', function () { + config(['mattitja-analytics.ingest_secret' => null]); + + handleRequest(createMiddleware(), createTestRequest()); + + Http::assertNothingSent(); +}); + +it('forwards the visitor Accept-Language header', function () { + $request = createTestRequest(); + $request->headers->set('Accept-Language', 'sv-SE,sv;q=0.9'); + + handleRequest(createMiddleware(), $request); + + Http::assertSent(fn ($request) => $request->hasHeader('Accept-Language', 'sv-SE,sv;q=0.9')); +}); + +it('ignores non-GET requests', function () { + handleRequest(createMiddleware(), createTestRequest(method: 'POST')); + + Http::assertNothingSent(); +}); + +it('ignores JSON responses and redirects', function () { + $middleware = createMiddleware(); + $request = createTestRequest(); + + foreach ([ + new \Illuminate\Http\JsonResponse(['ok' => true]), + new \Illuminate\Http\RedirectResponse('https://example.com/elsewhere'), + ] as $response) { + $result = $middleware->handle($request, fn () => $response); + $middleware->terminate($request, $result); + } + + Http::assertNothingSent(); +}); + +it('ignores requests expecting JSON', function () { + $request = createTestRequest(); + $request->headers->set('Accept', 'application/json'); + + handleRequest(createMiddleware(), $request); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 929ee80..f5d320d 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -45,7 +45,8 @@ it('adds env variable if .env exists and key is missing', function () { $envContent = File::get($this->envPath); - expect($envContent)->toContain('MATTITJA_ANALYTICS_SITE_KEY='); + expect($envContent)->toContain('MATTITJA_ANALYTICS_SITE_KEY=') + ->toContain('MATTITJA_ANALYTICS_INGEST_SECRET='); File::put($this->envPath, $originalContent); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 4724096..62940aa 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,6 +22,7 @@ class TestCase extends Orchestra public function getEnvironmentSetUp($app) { config()->set('mattitja-analytics.site_key', 'testsiteky'); + config()->set('mattitja-analytics.ingest_secret', 'test-secret'); config()->set('mattitja-analytics.enabled', true); } } diff --git a/tests/Unit/AnalyticsClientTest.php b/tests/Unit/AnalyticsClientTest.php index bbb8d49..bb77e52 100644 --- a/tests/Unit/AnalyticsClientTest.php +++ b/tests/Unit/AnalyticsClientTest.php @@ -6,8 +6,23 @@ use Mattitja\Analytics\Services\AnalyticsClient; beforeEach(function () { Http::fake(); + @unlink(storage_path('framework/mattitja-analytics.paused')); }); +afterEach(function () { + @unlink(storage_path('framework/mattitja-analytics.paused')); +}); + +function testPageview(): PageviewData +{ + return new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + ); +} + it('sends analytics data to the correct endpoint', function () { $client = new AnalyticsClient; $data = new PageviewData( @@ -21,7 +36,7 @@ it('sends analytics data to the correct endpoint', function () { $client->send($data); Http::assertSent(function ($request) { - return $request->url() === 'https://analytics.mattitja.cloud/api/send' && + return $request->url() === 'https://analytics.mattitja.se/api/send' && $request->method() === 'POST' && $request->data() === [ 'k' => 'testsiteky', @@ -35,7 +50,7 @@ it('sends analytics data to the correct endpoint', function () { it('handles API failures gracefully', function () { Http::fake([ - 'analytics.mattitja.cloud/*' => Http::response('Server Error', 500), + 'analytics.mattitja.se/*' => Http::response('Server Error', 500), ]); $client = new AnalyticsClient; @@ -140,3 +155,85 @@ it('excludes null referrer from payload', function () { return ! isset($request->data()['r']); }); }); + +it('sends the ingest secret as bearer token and forwards Accept-Language', function () { + (new AnalyticsClient)->send(new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + acceptLanguage: 'sv-SE,sv;q=0.9', + )); + + Http::assertSent(function ($request) { + return $request->hasHeader('Authorization', 'Bearer test-secret') + && $request->hasHeader('Accept-Language', 'sv-SE,sv;q=0.9') + && ! array_key_exists('acceptLanguage', $request->data()); + }); +}); + +it('logs a warning when the API rejects the pageview', function () { + Http::swap(new \Illuminate\Http\Client\Factory); + Http::fake(['*' => Http::response('', 401)]); + \Illuminate\Support\Facades\Log::spy(); + + (new AnalyticsClient)->send(new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + )); + + \Illuminate\Support\Facades\Log::shouldHaveReceived('warning') + ->withArgs(fn ($message, $context) => str_contains($message, 'rejected') && $context['status'] === 401) + ->once(); +}); + +it('pauses sending after a connection failure', function () { + Http::swap(new \Illuminate\Http\Client\Factory); + $attempts = 0; + Http::fake(function () use (&$attempts) { + $attempts++; + + throw new \Illuminate\Http\Client\ConnectionException('timeout'); + }); + + $client = new AnalyticsClient; + $client->send(testPageview()); + $client->send(testPageview()); + + expect($client->isPaused())->toBeTrue() + ->and($attempts)->toBe(1); +}); + +it('pauses sending after a server error', function () { + Http::swap(new \Illuminate\Http\Client\Factory); + Http::fake(['*' => Http::response('', 503)]); + + $client = new AnalyticsClient; + $client->send(testPageview()); + $client->send(testPageview()); + + expect($client->isPaused())->toBeTrue(); + Http::assertSentCount(1); +}); + +it('does not pause on client errors', function () { + Http::swap(new \Illuminate\Http\Client\Factory); + Http::fake(['*' => Http::response('', 401)]); + + $client = new AnalyticsClient; + $client->send(testPageview()); + + expect($client->isPaused())->toBeFalse(); +}); + +it('resumes sending after the pause expires', function () { + touch(storage_path('framework/mattitja-analytics.paused'), time() - 120); + + $client = new AnalyticsClient; + $client->send(testPageview()); + + Http::assertSentCount(1); + expect(file_exists(storage_path('framework/mattitja-analytics.paused')))->toBeFalse(); +});