refactor: use DTO and terminate middleware for better performance

- Add PageviewData readonly DTO class for type safety
- Update AnalyticsClient to accept PageviewData instead of array
- Move analytics sending to terminate() method (runs after response sent)
- Update all tests for new DTO pattern
This commit is contained in:
mathiaspalmqvist committed 2026-01-03 15:09:48 +01:00
1 parent 448ac3e4cf
commit e3911ca12c
6 files changed
+153 -115

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Mattitja\Analytics\Data;
readonly class PageviewData
{
public function __construct(
public string $siteKey,
public string $url,
public string $ip,
public string $userAgent,
public ?string $referrer = null,
) {}
public function toArray(): array
{
$data = [
'k' => $this->siteKey,
'u' => $this->url,
'ip' => $this->ip,
'ua' => $this->userAgent,
];
if ($this->referrer !== null && $this->referrer !== '') {
$data['r'] = $this->referrer;
}
return $data;
}
}
+24 -19
View File
@@ -4,9 +4,10 @@ namespace Mattitja\Analytics\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Mattitja\Analytics\Data\PageviewData;
use Mattitja\Analytics\Services\AnalyticsClient; use Mattitja\Analytics\Services\AnalyticsClient;
use Symfony\Component\HttpFoundation\Response;
class AnalyticsMiddleware class AnalyticsMiddleware
{ {
@@ -26,6 +27,8 @@ class AnalyticsMiddleware
'twitterbot', 'twitterbot',
]; ];
private ?PageviewData $pendingData = null;
/** /**
* Handle an incoming request and capture analytics data. * Handle an incoming request and capture analytics data.
*/ */
@@ -34,13 +37,22 @@ class AnalyticsMiddleware
$response = $next($request); $response = $next($request);
if ($this->shouldCaptureAnalytics($request)) { if ($this->shouldCaptureAnalytics($request)) {
$data = $this->buildAnalyticsData($request); $this->pendingData = $this->buildAnalyticsData($request);
$this->sendAnalytics($data);
} }
return $response; 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. * Determine if analytics should be captured for this request.
*/ */
@@ -136,34 +148,27 @@ class AnalyticsMiddleware
/** /**
* Build analytics data payload from request. * Build analytics data payload from request.
*/ */
protected function buildAnalyticsData(Request $request): array protected function buildAnalyticsData(Request $request): PageviewData
{ {
$data = [ return new PageviewData(
'k' => config('mattitja-analytics.site_key'), siteKey: config('mattitja-analytics.site_key'),
'u' => $request->fullUrl(), url: $request->fullUrl(),
'ip' => $request->ip(), ip: $request->ip(),
'ua' => $request->userAgent() ?? '', userAgent: $request->userAgent() ?? '',
]; referrer: $request->header('referer'),
);
$referrer = $request->header('referer');
if (! empty($referrer)) {
$data['r'] = $referrer;
}
return $data;
} }
/** /**
* Send analytics data to the API. * Send analytics data to the API.
*/ */
protected function sendAnalytics(array $data): void protected function sendAnalytics(PageviewData $data): void
{ {
try { try {
app(AnalyticsClient::class)->send($data); app(AnalyticsClient::class)->send($data);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Failed to send analytics data', [ Log::error('Failed to send analytics data', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'data' => $data,
]); ]);
} }
} }
+8 -45
View File
@@ -4,6 +4,7 @@ namespace Mattitja\Analytics\Services;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Mattitja\Analytics\Data\PageviewData;
class AnalyticsClient class AnalyticsClient
{ {
@@ -17,27 +18,18 @@ class AnalyticsClient
*/ */
private const int TIMEOUT = 5; private const int TIMEOUT = 5;
/**
* Required fields for analytics data.
*/
private const array REQUIRED_FIELDS = ['k', 'u', 'ip', 'ua'];
/** /**
* Send analytics data to the Mattitja Analytics API. * Send analytics data to the Mattitja Analytics API.
*
* @param array $data Analytics data payload
*/ */
public function send(array $data): void public function send(PageviewData $data): void
{ {
if (! $this->validateData($data)) { if (! $this->validateData($data)) {
return; return;
} }
$cleanData = $this->cleanData($data);
try { try {
Http::timeout(self::TIMEOUT) Http::timeout(self::TIMEOUT)
->post(self::ENDPOINT, $cleanData); ->post(self::ENDPOINT, $data->toArray());
} catch (\Exception) { } catch (\Exception) {
// Fail silently to not break user experience // Fail silently to not break user experience
} }
@@ -45,50 +37,21 @@ class AnalyticsClient
/** /**
* Validate that all required fields are present and not empty. * Validate that all required fields are present and not empty.
*
* @param array $data Analytics data to validate
* @return bool True if valid, false otherwise
*/ */
protected function validateData(array $data): bool protected function validateData(PageviewData $data): bool
{ {
foreach (self::REQUIRED_FIELDS as $field) { if (empty($data->siteKey) || empty($data->url) || empty($data->ip)) {
if (! isset($data[$field])) { Log::warning('Analytics: Required field is missing');
Log::warning("Analytics: Required field '{$field}' is missing");
return false; return false;
} }
// User agent can be empty string, but other fields cannot if (strlen($data->url) > 512) {
if ($field !== 'ua' && empty($data[$field])) { Log::warning('Analytics: URL exceeds 512 characters');
Log::warning("Analytics: Required field '{$field}' cannot be empty");
return false;
}
}
// URL max length check (API spec: max 512 characters)
if (isset($data['u']) && strlen($data['u']) > 512) {
Log::warning('Analytics: URL exceeds 512 characters, skipping');
return false; return false;
} }
return true; return true;
} }
/**
* Clean analytics data by removing empty optional fields.
*
* @param array $data Raw analytics data
* @return array Cleaned analytics data
*/
protected function cleanData(array $data): array
{
// Remove empty referrer URLs
if (isset($data['r']) && empty($data['r'])) {
unset($data['r']);
}
return $data;
}
} }
+6 -2
View File
@@ -25,9 +25,13 @@ function createMiddleware(): AnalyticsMiddleware
function handleRequest(AnalyticsMiddleware $middleware, Request $request): Response function handleRequest(AnalyticsMiddleware $middleware, Request $request): Response
{ {
return $middleware->handle($request, function ($req) { $response = $middleware->handle($request, function ($req) {
return new Response('Test content'); return new Response('Test content');
}); });
$middleware->terminate($request, $response);
return $response;
} }
it('captures analytics data for web requests', function () { it('captures analytics data for web requests', function () {
@@ -45,7 +49,7 @@ it('captures analytics data for web requests', function () {
$data = $request->data(); $data = $request->data();
return $request->url() === 'https://analytics.mattitja.cloud/api/send' && return $request->url() === 'https://analytics.mattitja.cloud/api/send' &&
$data['k'] === 'test-site-key' && $data['k'] === 'testsiteky' &&
$data['u'] === 'https://example.com/test' && $data['u'] === 'https://example.com/test' &&
$data['r'] === 'https://google.com' && $data['r'] === 'https://google.com' &&
$data['ip'] === '192.168.1.1' && $data['ip'] === '192.168.1.1' &&
+1 -1
View File
@@ -21,7 +21,7 @@ class TestCase extends Orchestra
public function getEnvironmentSetUp($app) public function getEnvironmentSetUp($app)
{ {
config()->set('mattitja-analytics.site_key', 'test-site-key'); config()->set('mattitja-analytics.site_key', 'testsiteky');
config()->set('mattitja-analytics.enabled', true); config()->set('mattitja-analytics.enabled', true);
} }
} }
+84 -48
View File
@@ -1,6 +1,7 @@
<?php <?php
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Mattitja\Analytics\Data\PageviewData;
use Mattitja\Analytics\Services\AnalyticsClient; use Mattitja\Analytics\Services\AnalyticsClient;
beforeEach(function () { beforeEach(function () {
@@ -9,20 +10,26 @@ beforeEach(function () {
it('sends analytics data to the correct endpoint', function () { it('sends analytics data to the correct endpoint', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
$data = [ $data = new PageviewData(
'k' => 'test-site-key', siteKey: 'testsiteky',
'u' => 'https://example.com/test', url: 'https://example.com/test',
'r' => 'https://google.com', ip: '192.168.1.1',
'ip' => '192.168.1.1', userAgent: 'Mozilla/5.0 Chrome',
'ua' => 'Mozilla/5.0 Chrome', referrer: 'https://google.com',
]; );
$client->send($data); $client->send($data);
Http::assertSent(function ($request) use ($data) { Http::assertSent(function ($request) {
return $request->url() === 'https://analytics.mattitja.cloud/api/send' && return $request->url() === 'https://analytics.mattitja.cloud/api/send' &&
$request->method() === 'POST' && $request->method() === 'POST' &&
$request->data() === $data; $request->data() === [
'k' => 'testsiteky',
'u' => 'https://example.com/test',
'ip' => '192.168.1.1',
'ua' => 'Mozilla/5.0 Chrome',
'r' => 'https://google.com',
];
}); });
}); });
@@ -32,43 +39,57 @@ it('handles API failures gracefully', function () {
]); ]);
$client = new AnalyticsClient; $client = new AnalyticsClient;
$data = [ $data = new PageviewData(
'k' => 'test-site-key', siteKey: 'testsiteky',
'u' => 'https://example.com/test', url: 'https://example.com/test',
'r' => 'https://google.com', ip: '192.168.1.1',
'ip' => '192.168.1.1', userAgent: 'Mozilla/5.0 Chrome',
'ua' => 'Mozilla/5.0 Chrome', referrer: 'https://google.com',
]; );
expect(fn () => $client->send($data))->not->toThrow(Exception::class); expect(fn () => $client->send($data))->not->toThrow(Exception::class);
}); });
it('uses hardcoded timeout', function () { it('sends data with correct site key', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
$data = [ $data = new PageviewData(
'k' => 'test-site-key', siteKey: 'testsiteky',
'u' => 'https://example.com/test', url: 'https://example.com/test',
'r' => '', ip: '192.168.1.1',
'ip' => '192.168.1.1', userAgent: 'Mozilla/5.0 Chrome',
'ua' => 'Mozilla/5.0 Chrome', );
];
$client->send($data); $client->send($data);
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
return $request->data()['k'] === 'test-site-key'; return $request->data()['k'] === 'testsiteky';
}); });
}); });
it('skips sending when required data fields are missing', function () { it('skips sending when required fields are empty', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
// Should not throw exceptions, just skip silently $client->send(new PageviewData(
$client->send([]); siteKey: '',
$client->send(['k' => 'test']); url: 'https://example.com',
$client->send(['k' => 'test', 'u' => 'url', 'ip' => '1.1.1.1']); ip: '192.168.1.1',
userAgent: 'Mozilla/5.0',
));
$client->send(new PageviewData(
siteKey: 'testsiteky',
url: '',
ip: '192.168.1.1',
userAgent: 'Mozilla/5.0',
));
$client->send(new PageviewData(
siteKey: 'testsiteky',
url: 'https://example.com',
ip: '',
userAgent: 'Mozilla/5.0',
));
// No HTTP requests should be sent for invalid data
Http::assertNothingSent(); Http::assertNothingSent();
}); });
@@ -76,31 +97,46 @@ it('skips sending when URL exceeds 512 characters', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
$longUrl = 'https://example.com/'.str_repeat('a', 500); $longUrl = 'https://example.com/'.str_repeat('a', 500);
$client->send([ $client->send(new PageviewData(
'k' => 'testsiteky', siteKey: 'testsiteky',
'u' => $longUrl, url: $longUrl,
'ip' => '192.168.1.1', ip: '192.168.1.1',
'ua' => 'Mozilla/5.0 Chrome', userAgent: 'Mozilla/5.0 Chrome',
]); ));
Http::assertNothingSent(); Http::assertNothingSent();
}); });
it('filters out empty referrer URLs', function () { it('excludes empty referrer from payload', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
$data = [ $data = new PageviewData(
'k' => 'test-site-key', siteKey: 'testsiteky',
'u' => 'https://example.com/test', url: 'https://example.com/test',
'r' => '', ip: '192.168.1.1',
'ip' => '192.168.1.1', userAgent: 'Mozilla/5.0 Chrome',
'ua' => 'Mozilla/5.0 Chrome', referrer: '',
]; );
$client->send($data); $client->send($data);
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
$sentData = $request->data(); return ! isset($request->data()['r']);
});
return ! isset($sentData['r']); });
it('excludes null referrer from payload', function () {
$client = new AnalyticsClient;
$data = new PageviewData(
siteKey: 'testsiteky',
url: 'https://example.com/test',
ip: '192.168.1.1',
userAgent: 'Mozilla/5.0 Chrome',
referrer: null,
);
$client->send($data);
Http::assertSent(function ($request) {
return ! isset($request->data()['r']);
}); });
}); });