From e3911ca12cad4dad41873c9a60e76975e607bc3d Mon Sep 17 00:00:00 2001 From: Mathias Palmqvist Date: Sat, 3 Jan 2026 15:09:48 +0100 Subject: [PATCH] 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 --- src/Data/PageviewData.php | 30 +++++ src/Middleware/AnalyticsMiddleware.php | 43 +++---- src/Services/AnalyticsClient.php | 55 ++------- tests/Feature/AnalyticsMiddlewareTest.php | 8 +- tests/TestCase.php | 2 +- tests/Unit/AnalyticsClientTest.php | 132 ++++++++++++++-------- 6 files changed, 154 insertions(+), 116 deletions(-) create mode 100644 src/Data/PageviewData.php diff --git a/src/Data/PageviewData.php b/src/Data/PageviewData.php new file mode 100644 index 0000000..414ec7f --- /dev/null +++ b/src/Data/PageviewData.php @@ -0,0 +1,30 @@ + $this->siteKey, + 'u' => $this->url, + 'ip' => $this->ip, + 'ua' => $this->userAgent, + ]; + + if ($this->referrer !== null && $this->referrer !== '') { + $data['r'] = $this->referrer; + } + + return $data; + } +} diff --git a/src/Middleware/AnalyticsMiddleware.php b/src/Middleware/AnalyticsMiddleware.php index e599607..4725788 100644 --- a/src/Middleware/AnalyticsMiddleware.php +++ b/src/Middleware/AnalyticsMiddleware.php @@ -4,9 +4,10 @@ namespace Mattitja\Analytics\Middleware; use Closure; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; +use Mattitja\Analytics\Data\PageviewData; use Mattitja\Analytics\Services\AnalyticsClient; +use Symfony\Component\HttpFoundation\Response; class AnalyticsMiddleware { @@ -26,6 +27,8 @@ class AnalyticsMiddleware 'twitterbot', ]; + private ?PageviewData $pendingData = null; + /** * Handle an incoming request and capture analytics data. */ @@ -34,13 +37,22 @@ class AnalyticsMiddleware $response = $next($request); if ($this->shouldCaptureAnalytics($request)) { - $data = $this->buildAnalyticsData($request); - $this->sendAnalytics($data); + $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. */ @@ -136,34 +148,27 @@ class AnalyticsMiddleware /** * Build analytics data payload from request. */ - protected function buildAnalyticsData(Request $request): array + protected function buildAnalyticsData(Request $request): PageviewData { - $data = [ - 'k' => config('mattitja-analytics.site_key'), - 'u' => $request->fullUrl(), - 'ip' => $request->ip(), - 'ua' => $request->userAgent() ?? '', - ]; - - $referrer = $request->header('referer'); - if (! empty($referrer)) { - $data['r'] = $referrer; - } - - return $data; + return new PageviewData( + siteKey: config('mattitja-analytics.site_key'), + url: $request->fullUrl(), + ip: $request->ip(), + userAgent: $request->userAgent() ?? '', + referrer: $request->header('referer'), + ); } /** * Send analytics data to the API. */ - protected function sendAnalytics(array $data): void + protected function sendAnalytics(PageviewData $data): void { try { app(AnalyticsClient::class)->send($data); } catch (\Exception $e) { Log::error('Failed to send analytics data', [ 'error' => $e->getMessage(), - 'data' => $data, ]); } } diff --git a/src/Services/AnalyticsClient.php b/src/Services/AnalyticsClient.php index 8039fc9..1bf3e63 100644 --- a/src/Services/AnalyticsClient.php +++ b/src/Services/AnalyticsClient.php @@ -4,6 +4,7 @@ namespace Mattitja\Analytics\Services; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Mattitja\Analytics\Data\PageviewData; class AnalyticsClient { @@ -17,27 +18,18 @@ class AnalyticsClient */ 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. - * - * @param array $data Analytics data payload */ - public function send(array $data): void + public function send(PageviewData $data): void { if (! $this->validateData($data)) { return; } - $cleanData = $this->cleanData($data); - try { Http::timeout(self::TIMEOUT) - ->post(self::ENDPOINT, $cleanData); + ->post(self::ENDPOINT, $data->toArray()); } catch (\Exception) { // Fail silently to not break user experience } @@ -45,50 +37,21 @@ class AnalyticsClient /** * 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 (! isset($data[$field])) { - Log::warning("Analytics: Required field '{$field}' is missing"); + if (empty($data->siteKey) || empty($data->url) || empty($data->ip)) { + Log::warning('Analytics: Required field is missing'); - return false; - } - - // User agent can be empty string, but other fields cannot - if ($field !== 'ua' && empty($data[$field])) { - Log::warning("Analytics: Required field '{$field}' cannot be empty"); - - return false; - } + 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'); + if (strlen($data->url) > 512) { + Log::warning('Analytics: URL exceeds 512 characters'); return false; } 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; - } } diff --git a/tests/Feature/AnalyticsMiddlewareTest.php b/tests/Feature/AnalyticsMiddlewareTest.php index 7cc0be8..4e6668a 100644 --- a/tests/Feature/AnalyticsMiddlewareTest.php +++ b/tests/Feature/AnalyticsMiddlewareTest.php @@ -25,9 +25,13 @@ function createMiddleware(): AnalyticsMiddleware function handleRequest(AnalyticsMiddleware $middleware, Request $request): Response { - return $middleware->handle($request, function ($req) { + $response = $middleware->handle($request, function ($req) { return new Response('Test content'); }); + + $middleware->terminate($request, $response); + + return $response; } it('captures analytics data for web requests', function () { @@ -45,7 +49,7 @@ it('captures analytics data for web requests', function () { $data = $request->data(); return $request->url() === 'https://analytics.mattitja.cloud/api/send' && - $data['k'] === 'test-site-key' && + $data['k'] === 'testsiteky' && $data['u'] === 'https://example.com/test' && $data['r'] === 'https://google.com' && $data['ip'] === '192.168.1.1' && diff --git a/tests/TestCase.php b/tests/TestCase.php index 19377fe..4724096 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,7 +21,7 @@ class TestCase extends Orchestra 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); } } diff --git a/tests/Unit/AnalyticsClientTest.php b/tests/Unit/AnalyticsClientTest.php index 6bd28f8..bbb8d49 100644 --- a/tests/Unit/AnalyticsClientTest.php +++ b/tests/Unit/AnalyticsClientTest.php @@ -1,6 +1,7 @@ 'test-site-key', - 'u' => 'https://example.com/test', - 'r' => 'https://google.com', - 'ip' => '192.168.1.1', - 'ua' => 'Mozilla/5.0 Chrome', - ]; + $data = new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + referrer: 'https://google.com', + ); $client->send($data); - Http::assertSent(function ($request) use ($data) { + Http::assertSent(function ($request) { return $request->url() === 'https://analytics.mattitja.cloud/api/send' && $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; - $data = [ - 'k' => 'test-site-key', - 'u' => 'https://example.com/test', - 'r' => 'https://google.com', - 'ip' => '192.168.1.1', - 'ua' => 'Mozilla/5.0 Chrome', - ]; + $data = new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + referrer: 'https://google.com', + ); 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; - $data = [ - 'k' => 'test-site-key', - 'u' => 'https://example.com/test', - 'r' => '', - 'ip' => '192.168.1.1', - 'ua' => 'Mozilla/5.0 Chrome', - ]; + $data = new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + ); $client->send($data); 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; - // Should not throw exceptions, just skip silently - $client->send([]); - $client->send(['k' => 'test']); - $client->send(['k' => 'test', 'u' => 'url', 'ip' => '1.1.1.1']); + $client->send(new PageviewData( + siteKey: '', + url: 'https://example.com', + 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(); }); @@ -76,31 +97,46 @@ it('skips sending when URL exceeds 512 characters', function () { $client = new AnalyticsClient; $longUrl = 'https://example.com/'.str_repeat('a', 500); - $client->send([ - 'k' => 'testsiteky', - 'u' => $longUrl, - 'ip' => '192.168.1.1', - 'ua' => 'Mozilla/5.0 Chrome', - ]); + $client->send(new PageviewData( + siteKey: 'testsiteky', + url: $longUrl, + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + )); Http::assertNothingSent(); }); -it('filters out empty referrer URLs', function () { +it('excludes empty referrer from payload', function () { $client = new AnalyticsClient; - $data = [ - 'k' => 'test-site-key', - 'u' => 'https://example.com/test', - 'r' => '', - 'ip' => '192.168.1.1', - 'ua' => 'Mozilla/5.0 Chrome', - ]; + $data = new PageviewData( + siteKey: 'testsiteky', + url: 'https://example.com/test', + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0 Chrome', + referrer: '', + ); $client->send($data); Http::assertSent(function ($request) { - $sentData = $request->data(); - - return ! isset($sentData['r']); + return ! isset($request->data()['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']); }); });