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:
mathiaspalmqvist committed 2026-11-12 09:08:27 +01:00
1 parent e3911ca12c
commit 19f632d652
11 files changed
+298 -26

No files matched your search

+15 -3
View File
@@ -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 <ingest secret>`, 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
+13
View File
@@ -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
+8 -4
View File
@@ -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.');
}
}
}
+10 -2
View 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);
}
}
}
+1
View File
@@ -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
+34 -3
View File
@@ -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(),
]);
+67 -10
View File
@@ -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.
*/
+48 -1
View File
@@ -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();
});
+2 -1
View File
@@ -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);
});
+1
View File
@@ -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);
}
}
+99 -2
View File
@@ -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();
});