4 Commits
Author SHA1 Message Date
mathiaspalmqvist e3911ca12c 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
2026-01-03 15:09:48 +01:00
mathiaspalmqvist 448ac3e4cf feat: added install script 2026-01-03 15:01:11 +01:00
mathiaspalmqvist 630620e4e6 fix: added logs instead of exception 2026-01-03 14:55:34 +01:00
mathiaspalmqvist f25b67f6f1 framework updates 2026-01-03 14:45:58 +01:00
10 changed files with 932 additions and 622 deletions

No files matched your search

Generated
+631 -518
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -3,7 +3,9 @@
namespace Mattitja\Analytics; namespace Mattitja\Analytics;
use Illuminate\Routing\Router; use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Mattitja\Analytics\Console\InstallCommand;
use Mattitja\Analytics\Middleware\AnalyticsMiddleware; use Mattitja\Analytics\Middleware\AnalyticsMiddleware;
use Mattitja\Analytics\Services\AnalyticsClient; use Mattitja\Analytics\Services\AnalyticsClient;
@@ -33,7 +35,14 @@ class AnalyticsServiceProvider extends ServiceProvider
__DIR__.'/../config/mattitja-analytics.php' => config_path('mattitja-analytics.php'), __DIR__.'/../config/mattitja-analytics.php' => config_path('mattitja-analytics.php'),
], 'mattitja-analytics-config'); ], 'mattitja-analytics-config');
if ($this->app->runningInConsole()) {
$this->commands([
InstallCommand::class,
]);
}
$this->registerMiddleware(); $this->registerMiddleware();
$this->warnIfSiteKeyMissing();
} }
/** /**
@@ -46,4 +55,18 @@ class AnalyticsServiceProvider extends ServiceProvider
$router->aliasMiddleware('mattitja-analytics', AnalyticsMiddleware::class); $router->aliasMiddleware('mattitja-analytics', AnalyticsMiddleware::class);
$router->pushMiddlewareToGroup('web', AnalyticsMiddleware::class); $router->pushMiddlewareToGroup('web', AnalyticsMiddleware::class);
} }
/**
* Log a warning if site_key is not configured.
*/
private function warnIfSiteKeyMissing(): void
{
if ($this->app->runningInConsole()) {
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.');
}
}
} }
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Mattitja\Analytics\Console;
use Illuminate\Console\Command;
class InstallCommand extends Command
{
protected $signature = 'mattitja:install';
protected $description = 'Install Mattitja Analytics package';
public function handle(): int
{
$this->call('vendor:publish', [
'--tag' => 'mattitja-analytics-config',
]);
$this->addEnvVariable();
$this->components->info('Mattitja Analytics installed successfully!');
$this->components->warn('Add your site key to .env: MATTITJA_ANALYTICS_SITE_KEY=your-key');
return Command::SUCCESS;
}
private function addEnvVariable(): void
{
$envPath = base_path('.env');
if (! file_exists($envPath)) {
return;
}
$envContent = file_get_contents($envPath);
if (! str_contains($envContent, 'MATTITJA_ANALYTICS_SITE_KEY')) {
file_put_contents($envPath, $envContent."\nMATTITJA_ANALYTICS_SITE_KEY=\n");
}
}
}
+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,
]); ]);
} }
} }
+17 -42
View File
@@ -3,7 +3,8 @@
namespace Mattitja\Analytics\Services; namespace Mattitja\Analytics\Services;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use InvalidArgumentException; 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
*
* @throws InvalidArgumentException If required fields are missing
*/ */
public function send(array $data): void public function send(PageviewData $data): void
{ {
$this->validateData($data); if (! $this->validateData($data)) {
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,38 +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
*
* @throws InvalidArgumentException If required fields are missing
*/ */
protected function validateData(array $data): void 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');
throw new InvalidArgumentException("Required field '{$field}' is missing");
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');
throw new InvalidArgumentException("Required field '{$field}' cannot be empty");
} 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;
} }
} }
+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' &&
+65
View File
@@ -0,0 +1,65 @@
<?php
use Illuminate\Support\Facades\File;
beforeEach(function () {
$this->envPath = base_path('.env');
$this->configPath = config_path('mattitja-analytics.php');
if (File::exists($this->configPath)) {
File::delete($this->configPath);
}
});
afterEach(function () {
if (File::exists($this->configPath)) {
File::delete($this->configPath);
}
});
it('publishes the config file', function () {
$this->artisan('mattitja:install')
->assertSuccessful();
expect(File::exists($this->configPath))->toBeTrue();
});
it('displays success message', function () {
$this->artisan('mattitja:install')
->expectsOutputToContain('Mattitja Analytics installed successfully')
->assertSuccessful();
});
it('displays warning about site key', function () {
$this->artisan('mattitja:install')
->expectsOutputToContain('MATTITJA_ANALYTICS_SITE_KEY')
->assertSuccessful();
});
it('adds env variable if .env exists and key is missing', function () {
$originalContent = "APP_NAME=Test\n";
File::put($this->envPath, $originalContent);
$this->artisan('mattitja:install')
->assertSuccessful();
$envContent = File::get($this->envPath);
expect($envContent)->toContain('MATTITJA_ANALYTICS_SITE_KEY=');
File::put($this->envPath, $originalContent);
});
it('does not duplicate env variable if already exists', function () {
$originalContent = "APP_NAME=Test\nMATTITJA_ANALYTICS_SITE_KEY=existing-key\n";
File::put($this->envPath, $originalContent);
$this->artisan('mattitja:install')
->assertSuccessful();
$envContent = File::get($this->envPath);
expect(substr_count($envContent, 'MATTITJA_ANALYTICS_SITE_KEY'))->toBe(1);
File::put($this->envPath, "APP_NAME=Test\n");
});
+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);
} }
} }
+94 -40
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,57 +39,104 @@ 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('validates required data fields', function () { it('skips sending when required fields are empty', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
expect(fn () => $client->send([]))->toThrow(InvalidArgumentException::class) $client->send(new PageviewData(
->and(fn () => $client->send(['k' => 'test']))->toThrow(InvalidArgumentException::class) siteKey: '',
->and(fn () => $client->send(['k' => 'test', 'u' => 'url', 'ip' => '1.1.1.1']))->toThrow(InvalidArgumentException::class); 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',
));
Http::assertNothingSent();
}); });
it('filters out empty referrer URLs', function () { it('skips sending when URL exceeds 512 characters', function () {
$client = new AnalyticsClient; $client = new AnalyticsClient;
$data = [ $longUrl = 'https://example.com/'.str_repeat('a', 500);
'k' => 'test-site-key',
'u' => 'https://example.com/test', $client->send(new PageviewData(
'r' => '', siteKey: 'testsiteky',
'ip' => '192.168.1.1', url: $longUrl,
'ua' => 'Mozilla/5.0 Chrome', ip: '192.168.1.1',
]; userAgent: 'Mozilla/5.0 Chrome',
));
Http::assertNothingSent();
});
it('excludes empty 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: '',
);
$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']);
}); });
}); });