Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a05515d3c | ||
|
|
9dc3dc3d9b
|
||
|
|
9d268278a0 | ||
|
|
19f632d652
|
||
|
|
e3911ca12c | ||
|
|
448ac3e4cf | ||
|
|
630620e4e6 | ||
|
|
f25b67f6f1 |
No files matched your search
@@ -5,7 +5,7 @@ A Laravel middleware package for sending analytics data to the Mattitja Analytic
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require mattitjaab/mattitja-analytics
|
composer require mattitja/mattitja-analytics
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -16,13 +16,16 @@ Publish the configuration file (optional):
|
|||||||
php artisan vendor:publish --tag=mattitja-analytics-config
|
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
|
```env
|
||||||
MATTITJA_ANALYTICS_SITE_KEY=your-site-key
|
MATTITJA_ANALYTICS_SITE_KEY=your-site-key
|
||||||
|
MATTITJA_ANALYTICS_INGEST_SECRET=your-ingest-secret
|
||||||
MATTITJA_ANALYTICS_ENABLED=true
|
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
|
## Usage
|
||||||
|
|
||||||
The package automatically registers middleware for the `web` group and starts collecting analytics data immediately after installation and configuration.
|
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
|
### Basic Settings
|
||||||
- `MATTITJA_ANALYTICS_SITE_KEY` - Your site key from Mattitja Analytics (required)
|
- `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_ENABLED` - Enable/disable tracking (default: true)
|
||||||
- `MATTITJA_ANALYTICS_BOTS` - Track bots (default: false)
|
- `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
|
## 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
|
- `k` - Site key
|
||||||
- `u` - Full URL of the visited page
|
- `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
|
- `ip` - Visitor's complete IP address
|
||||||
- `ua` - User agent string
|
- `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
|
## 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)
|
- 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
|
- Bot traffic is filtered out by default
|
||||||
- Do Not Track header is respected by default
|
- Do Not Track header is respected by default
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "mattitjaab/mattitja-analytics",
|
"name": "mattitja/mattitja-analytics",
|
||||||
"description": "Laravel middleware package for sending analytics data to Mattitja Analytics API",
|
"description": "Laravel middleware package for sending analytics data to Mattitja Analytics API",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Generated
+633
-520
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,19 @@ return [
|
|||||||
|
|
||||||
'site_key' => env('MATTITJA_ANALYTICS_SITE_KEY'),
|
'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
|
| Capture Configuration
|
||||||
|
|||||||
@@ -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->warnIfConfigMissing();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,4 +55,22 @@ 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 or ingest_secret is not configured.
|
||||||
|
*/
|
||||||
|
private function warnIfConfigMissing(): void
|
||||||
|
{
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?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');
|
||||||
|
$this->components->warn('Add your ingest secret to .env: MATTITJA_ANALYTICS_INGEST_SECRET=your-secret');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addEnvVariable(): void
|
||||||
|
{
|
||||||
|
$envPath = base_path('.env');
|
||||||
|
|
||||||
|
if (! file_exists($envPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$envContent = file_get_contents($envPath);
|
||||||
|
$additions = '';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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 ?string $acceptLanguage = 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 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.
|
||||||
*/
|
*/
|
||||||
@@ -33,21 +36,32 @@ class AnalyticsMiddleware
|
|||||||
{
|
{
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
|
|
||||||
if ($this->shouldCaptureAnalytics($request)) {
|
if ($this->shouldCaptureAnalytics($request, $response)) {
|
||||||
$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.
|
||||||
*/
|
*/
|
||||||
protected function shouldCaptureAnalytics(Request $request): bool
|
protected function shouldCaptureAnalytics(Request $request, Response $response): bool
|
||||||
{
|
{
|
||||||
return $this->isAnalyticsEnabled()
|
return $this->isAnalyticsEnabled()
|
||||||
&& $this->hasSiteKey()
|
&& $this->hasSiteKey()
|
||||||
|
&& $this->hasIngestSecret()
|
||||||
|
&& $this->isPageview($request, $response)
|
||||||
&& ! $this->isIgnoredPath($request)
|
&& ! $this->isIgnoredPath($request)
|
||||||
&& ! $this->isIgnoredIp($request)
|
&& ! $this->isIgnoredIp($request)
|
||||||
&& ! $this->shouldIgnoreBot($request)
|
&& ! $this->shouldIgnoreBot($request)
|
||||||
@@ -70,6 +84,34 @@ class AnalyticsMiddleware
|
|||||||
return ! empty(config('mattitja-analytics.site_key'));
|
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.
|
* Check if bot traffic should be ignored.
|
||||||
*/
|
*/
|
||||||
@@ -136,34 +178,28 @@ 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'),
|
||||||
|
acceptLanguage: $request->header('Accept-Language'),
|
||||||
$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 (\Throwable $e) {
|
||||||
Log::error('Failed to send analytics data', [
|
Log::error('Failed to send analytics data', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'data' => $data,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,80 +3,112 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The Mattitja Analytics API endpoint.
|
* The Mattitja Analytics API endpoint.
|
||||||
*/
|
*/
|
||||||
private const string ENDPOINT = 'https://analytics.mattitja.cloud/api/send';
|
private const string ENDPOINT = 'https://analytics.mattitja.se/api/send';
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP request timeout in seconds.
|
|
||||||
*/
|
|
||||||
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) || $this->isPaused()) {
|
||||||
|
return;
|
||||||
$cleanData = $this->cleanData($data);
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Http::timeout(self::TIMEOUT)
|
$response = Http::withOptions([
|
||||||
->post(self::ENDPOINT, $cleanData);
|
'timeout' => (float) config('mattitja-analytics.timeout', 1),
|
||||||
} catch (\Exception) {
|
'connect_timeout' => (float) config('mattitja-analytics.connect_timeout', 0.5),
|
||||||
// Fail silently to not break user experience
|
])
|
||||||
|
->acceptJson()
|
||||||
|
->withToken((string) config('mattitja-analytics.ingest_secret'))
|
||||||
|
->withHeaders(array_filter([
|
||||||
|
'Accept-Language' => $data->acceptLanguage,
|
||||||
|
]))
|
||||||
|
->post(self::ENDPOINT, $data->toArray());
|
||||||
|
|
||||||
|
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.
|
* 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 () {
|
||||||
@@ -44,8 +48,8 @@ it('captures analytics data for web requests', function () {
|
|||||||
Http::assertSent(function ($request) {
|
Http::assertSent(function ($request) {
|
||||||
$data = $request->data();
|
$data = $request->data();
|
||||||
|
|
||||||
return $request->url() === 'https://analytics.mattitja.cloud/api/send' &&
|
return $request->url() === 'https://analytics.mattitja.se/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' &&
|
||||||
@@ -136,3 +140,50 @@ it('handles empty user agent gracefully', function () {
|
|||||||
return array_key_exists('ua', $data) && $data['ua'] === '';
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?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=')
|
||||||
|
->toContain('MATTITJA_ANALYTICS_INGEST_SECRET=');
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
+2
-1
@@ -21,7 +21,8 @@ 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.ingest_secret', 'test-secret');
|
||||||
config()->set('mattitja-analytics.enabled', true);
|
config()->set('mattitja-analytics.enabled', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,88 +1,239 @@
|
|||||||
<?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 () {
|
||||||
Http::fake();
|
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 () {
|
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.se/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',
|
||||||
|
];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles API failures gracefully', function () {
|
it('handles API failures gracefully', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'analytics.mattitja.cloud/*' => Http::response('Server Error', 500),
|
'analytics.mattitja.se/*' => Http::response('Server Error', 500),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user