Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3911ca12c | ||
|
|
448ac3e4cf | ||
|
|
630620e4e6 | ||
|
|
f25b67f6f1 |
No files matched your search
Generated
+631
-518
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,9 @@
|
||||
namespace Mattitja\Analytics;
|
||||
|
||||
use Illuminate\Routing\Router;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Mattitja\Analytics\Console\InstallCommand;
|
||||
use Mattitja\Analytics\Middleware\AnalyticsMiddleware;
|
||||
use Mattitja\Analytics\Services\AnalyticsClient;
|
||||
|
||||
@@ -33,7 +35,14 @@ class AnalyticsServiceProvider extends ServiceProvider
|
||||
__DIR__.'/../config/mattitja-analytics.php' => config_path('mattitja-analytics.php'),
|
||||
], 'mattitja-analytics-config');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
InstallCommand::class,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->registerMiddleware();
|
||||
$this->warnIfSiteKeyMissing();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,4 +55,18 @@ class AnalyticsServiceProvider extends ServiceProvider
|
||||
$router->aliasMiddleware('mattitja-analytics', 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
namespace Mattitja\Analytics\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use InvalidArgumentException;
|
||||
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
|
||||
*
|
||||
* @throws InvalidArgumentException If required fields are missing
|
||||
*/
|
||||
public function send(array $data): void
|
||||
public function send(PageviewData $data): void
|
||||
{
|
||||
$this->validateData($data);
|
||||
|
||||
$cleanData = $this->cleanData($data);
|
||||
if (! $this->validateData($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(self::TIMEOUT)
|
||||
->post(self::ENDPOINT, $cleanData);
|
||||
->post(self::ENDPOINT, $data->toArray());
|
||||
} catch (\Exception) {
|
||||
// Fail silently to not break user experience
|
||||
}
|
||||
@@ -45,38 +37,21 @@ class AnalyticsClient
|
||||
|
||||
/**
|
||||
* 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 (! isset($data[$field])) {
|
||||
throw new InvalidArgumentException("Required field '{$field}' is missing");
|
||||
}
|
||||
if (empty($data->siteKey) || empty($data->url) || empty($data->ip)) {
|
||||
Log::warning('Analytics: Required field is missing');
|
||||
|
||||
// User agent can be empty string, but other fields cannot
|
||||
if ($field !== 'ua' && empty($data[$field])) {
|
||||
throw new InvalidArgumentException("Required field '{$field}' cannot be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 false;
|
||||
}
|
||||
|
||||
return $data;
|
||||
if (strlen($data->url) > 512) {
|
||||
Log::warning('Analytics: URL exceeds 512 characters');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mattitja\Analytics\Data\PageviewData;
|
||||
use Mattitja\Analytics\Services\AnalyticsClient;
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -9,20 +10,26 @@ beforeEach(function () {
|
||||
|
||||
it('sends analytics data to the correct endpoint', 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',
|
||||
);
|
||||
|
||||
$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,57 +39,104 @@ 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('validates required data fields', function () {
|
||||
it('skips sending when required fields are empty', function () {
|
||||
$client = new AnalyticsClient;
|
||||
|
||||
expect(fn () => $client->send([]))->toThrow(InvalidArgumentException::class)
|
||||
->and(fn () => $client->send(['k' => 'test']))->toThrow(InvalidArgumentException::class)
|
||||
->and(fn () => $client->send(['k' => 'test', 'u' => 'url', 'ip' => '1.1.1.1']))->toThrow(InvalidArgumentException::class);
|
||||
$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',
|
||||
));
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('filters out empty referrer URLs', function () {
|
||||
it('skips sending when URL exceeds 512 characters', 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',
|
||||
];
|
||||
$longUrl = 'https://example.com/'.str_repeat('a', 500);
|
||||
|
||||
$client->send(new PageviewData(
|
||||
siteKey: 'testsiteky',
|
||||
url: $longUrl,
|
||||
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);
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user