Analytics administration dashboard

* Page-view tracking
* Unique visitor and session metrics
* Content-view events
* Search analytics
* Conversion tracking
* Form-submission events
* Commerce events
* Site-aware analytics
* Privacy-conscious visitor hashing
* Optional IP anonymisation
* Do Not Track support
* Configurable excluded paths
* Daily metric aggregation
* Top-page reporting
* Recent-event activity
* Report definitions
* Scheduled report foundation
* CSV and JSON report formats
* Report execution history schema
* Analytics retention and pruning
* Administration navigation
* Analytics permissions
* Module registration
* Feature test
This commit is contained in:
Mark Gregory
2026-07-24 13:06:48 +01:00
parent 5dbb04e3ad
commit 76e29fab03
22 changed files with 305 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'enabled' => (bool) env('ANALYTICS_ENABLED', true),
'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true),
'anonymize_ip' => (bool) env('ANALYTICS_ANONYMIZE_IP', true),
'retention_days' => (int) env('ANALYTICS_RETENTION_DAYS', 365),
'exclude_paths' => array_values(array_filter(array_map('trim', explode(',', (string) env('ANALYTICS_EXCLUDE_PATHS', 'administration,api'))))),
'queue' => env('ANALYTICS_QUEUE', 'default'),
];
@@ -0,0 +1,9 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Services\AnalyticsAggregator;
final class AggregateAnalytics extends Command
{
protected $signature='analytics:aggregate {--date=}'; protected $description='Aggregate raw analytics events into daily metrics.';
public function handle(AnalyticsAggregator $aggregator): int { $this->info('Metrics written: '.$aggregator->aggregate($this->option('date'))); return self::SUCCESS; }
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Models\AnalyticsEvent;
final class PruneAnalytics extends Command
{
protected $signature='analytics:prune'; protected $description='Remove analytics events older than the configured retention period.';
public function handle(): int { $count=AnalyticsEvent::where('occurred_at','<',now()->subDays(config('analytics.retention_days',365)))->delete(); $this->info("Deleted {$count} events."); return self::SUCCESS; }
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Models\AnalyticsReport;
final class RunScheduledReports extends Command
{
protected $signature='analytics:reports'; protected $description='Queue analytics reports that are due.';
public function handle(): int
{
$count=AnalyticsReport::where('is_active',true)->whereNotNull('next_run_at')->where('next_run_at','<=',now())->update(['last_run_at'=>now(),'next_run_at'=>now()->addDay()]);
$this->info("Processed {$count} scheduled reports."); return self::SUCCESS;
}
}
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('analytics_events', function (Blueprint $table) {
$table->id(); $table->uuid('event_id')->unique(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('event_type', 40)->index(); $table->string('path', 2048)->nullable(); $table->string('title')->nullable();
$table->string('referrer', 2048)->nullable(); $table->string('visitor_hash', 64)->nullable()->index();
$table->string('session_hash', 64)->nullable()->index(); $table->string('ip_hash', 64)->nullable();
$table->string('country_code', 2)->nullable()->index(); $table->string('device_type', 20)->nullable()->index();
$table->string('browser', 40)->nullable(); $table->string('operating_system', 40)->nullable();
$table->nullableMorphs('subject'); $table->json('properties')->nullable(); $table->timestamp('occurred_at')->index(); $table->timestamps();
$table->index(['site_id','event_type','occurred_at']);
});
Schema::create('analytics_daily_metrics', function (Blueprint $table) {
$table->id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); $table->date('metric_date')->index();
$table->string('metric', 60); $table->string('dimension', 120)->default('all'); $table->unsignedBigInteger('value')->default(0);
$table->json('metadata')->nullable(); $table->timestamps(); $table->unique(['site_id','metric_date','metric','dimension'], 'analytics_daily_unique');
});
Schema::create('analytics_reports', function (Blueprint $table) {
$table->id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); $table->string('name');
$table->string('report_type', 40)->default('overview'); $table->json('filters')->nullable(); $table->json('columns')->nullable();
$table->string('schedule')->nullable(); $table->string('format', 10)->default('csv'); $table->json('recipients')->nullable();
$table->boolean('is_active')->default(true)->index(); $table->timestamp('last_run_at')->nullable(); $table->timestamp('next_run_at')->nullable()->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
});
Schema::create('analytics_report_runs', function (Blueprint $table) {
$table->id(); $table->foreignId('analytics_report_id')->constrained('analytics_reports')->cascadeOnDelete();
$table->string('status', 20)->default('pending')->index(); $table->string('disk')->nullable(); $table->string('path')->nullable();
$table->unsignedBigInteger('row_count')->default(0); $table->text('error')->nullable(); $table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable(); $table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('analytics_report_runs'); Schema::dropIfExists('analytics_reports');
Schema::dropIfExists('analytics_daily_metrics'); Schema::dropIfExists('analytics_events');
}
};
@@ -0,0 +1,30 @@
<?php
namespace Modules\Analytics\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\Analytics\Models\AnalyticsEvent;
use Modules\Analytics\Models\AnalyticsReport;
final class AnalyticsController extends Controller
{
public function index(Request $request): View
{
$days=max(1,min(365,(int)$request->integer('days',30))); $from=now()->subDays($days-1)->startOfDay();
$events=AnalyticsEvent::query()->where('occurred_at','>=',$from);
return view('analytics::index',[
'days'=>$days,'pageViews'=>$events->clone()->where('event_type','page_view')->count(),
'visitors'=>$events->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash'),
'conversions'=>$events->clone()->where('event_type','conversion')->count(),
'searches'=>$events->clone()->where('event_type','search')->count(),
'topPages'=>$events->clone()->where('event_type','page_view')->selectRaw('path, count(*) as total')->groupBy('path')->orderByDesc('total')->limit(10)->get(),
'recentEvents'=>$events->clone()->latest('occurred_at')->limit(20)->get(), 'reports'=>AnalyticsReport::latest()->limit(20)->get(),
]);
}
public function storeReport(Request $request): RedirectResponse
{
AnalyticsReport::create($request->validate(['name'=>'required|string|max:255','report_type'=>'required|in:overview,content,search,conversion,commerce','schedule'=>'nullable|string|max:50','format'=>'required|in:csv,json','is_active'=>'nullable|boolean']) + ['created_by'=>$request->user()->id,'is_active'=>$request->boolean('is_active',true)]);
return back()->with('status','Report created.');
}
public function destroyReport(AnalyticsReport $report): RedirectResponse { $report->delete(); return back()->with('status','Report deleted.'); }
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Analytics\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Modules\Analytics\Services\AnalyticsTracker;
final class TrackingController extends Controller
{
public function store(Request $request, AnalyticsTracker $tracker): JsonResponse
{
$data=$request->validate([
'event_type'=>['required',Rule::in(['page_view','search','conversion','content_view','form_submit','commerce'])],
'path'=>['nullable','string','max:2048'],'title'=>['nullable','string','max:255'],'referrer'=>['nullable','string','max:2048'],
'visitor_id'=>['nullable','string','max:255'],'session_id'=>['nullable','string','max:255'],
'subject_type'=>['nullable','string','max:255'],'subject_id'=>['nullable','integer'],'properties'=>['nullable','array'],
]);
$event=$tracker->track($request,$data);
return response()->json(['recorded'=>(bool)$event],$event?201:202);
}
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsEvent extends Model
{
protected $fillable=['event_id','site_id','event_type','path','title','referrer','visitor_hash','session_hash','ip_hash','country_code','device_type','browser','operating_system','subject_type','subject_id','properties','occurred_at'];
protected $casts=['properties'=>'array','occurred_at'=>'datetime'];
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsReport extends Model
{
protected $fillable=['site_id','name','report_type','filters','columns','schedule','format','recipients','is_active','last_run_at','next_run_at','created_by'];
protected $casts=['filters'=>'array','columns'=>'array','recipients'=>'array','is_active'=>'boolean','last_run_at'=>'datetime','next_run_at'=>'datetime'];
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsReportRun extends Model
{
protected $fillable=['analytics_report_id','status','disk','path','row_count','error','started_at','completed_at'];
protected $casts=['started_at'=>'datetime','completed_at'=>'datetime'];
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class DailyMetric extends Model
{
protected $table='analytics_daily_metrics';
protected $fillable=['site_id','metric_date','metric','dimension','value','metadata'];
protected $casts=['metric_date'=>'date','metadata'=>'array'];
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Analytics\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Analytics\Console\AggregateAnalytics;
use Modules\Analytics\Console\PruneAnalytics;
use Modules\Analytics\Console\RunScheduledReports;
final class AnalyticsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Analytics', 'Resources/views'), 'analytics');
if ($this->app->runningInConsole()) {
$this->commands([AggregateAnalytics::class, PruneAnalytics::class, RunScheduledReports::class]);
$this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
$schedule->command('analytics:aggregate')->hourly()->withoutOverlapping();
$schedule->command('analytics:reports')->everyFifteenMinutes()->withoutOverlapping();
$schedule->command('analytics:prune')->dailyAt('02:30')->withoutOverlapping();
});
}
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Analytics\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('Analytics', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('Analytics', 'Routes/api.php'));
}
}
@@ -0,0 +1,14 @@
<x-layouts.app :title="__('Analytics')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div class="flex flex-wrap items-center justify-between gap-3"><div><flux:heading size="xl">Analytics</flux:heading><flux:subheading>Visitor, content, search and conversion performance.</flux:subheading></div><form method="GET"><flux:select name="days" onchange="this.form.submit()"><option value="7" @selected($days===7)>7 days</option><option value="30" @selected($days===30)>30 days</option><option value="90" @selected($days===90)>90 days</option></flux:select></form></div>
@if(session('status'))<flux:callout variant="success">{{ session('status') }}</flux:callout>@endif
<div class="grid gap-4 md:grid-cols-4">
@foreach([['Page views',$pageViews],['Visitors',$visitors],['Conversions',$conversions],['Searches',$searches]] as [$label,$value])<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><div class="text-sm text-neutral-500">{{ $label }}</div><div class="mt-2 text-3xl font-semibold">{{ number_format($value) }}</div></div>@endforeach
</div>
<div class="grid gap-6 lg:grid-cols-2">
<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><flux:heading>Top pages</flux:heading><div class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-700">@forelse($topPages as $page)<div class="flex justify-between gap-4 py-3"><span class="truncate">{{ $page->path ?: '/' }}</span><strong>{{ number_format($page->total) }}</strong></div>@empty<p class="py-4 text-sm text-neutral-500">No page views recorded.</p>@endforelse</div></div>
<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><flux:heading>Recent events</flux:heading><div class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-700">@forelse($recentEvents as $event)<div class="py-3"><div class="flex justify-between gap-4"><strong>{{ str($event->event_type)->replace('_',' ')->title() }}</strong><span class="text-xs text-neutral-500">{{ $event->occurred_at?->diffForHumans() }}</span></div><p class="truncate text-sm text-neutral-500">{{ $event->path ?: data_get($event->properties,'query','—') }}</p></div>@empty<p class="py-4 text-sm text-neutral-500">No events recorded.</p>@endforelse</div></div>
</div>
@can('analytics.manage')<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><flux:heading>Scheduled reports</flux:heading><form method="POST" action="{{ route('administration.analytics.reports.store') }}" class="mt-4 grid gap-4 md:grid-cols-5">@csrf<flux:input name="name" label="Name" required/><flux:select name="report_type" label="Type"><option value="overview">Overview</option><option value="content">Content</option><option value="search">Search</option><option value="conversion">Conversion</option><option value="commerce">Commerce</option></flux:select><flux:input name="schedule" label="Schedule" placeholder="daily"/><flux:select name="format" label="Format"><option value="csv">CSV</option><option value="json">JSON</option></flux:select><div class="flex items-end"><flux:button type="submit" variant="primary">Create report</flux:button></div></form><div class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-700">@forelse($reports as $report)<div class="flex items-center justify-between py-3"><div><strong>{{ $report->name }}</strong><p class="text-sm text-neutral-500">{{ ucfirst($report->report_type) }} · {{ strtoupper($report->format) }}</p></div><form method="POST" action="{{ route('administration.analytics.reports.destroy',$report) }}">@csrf @method('DELETE')<flux:button type="submit" variant="danger" size="sm">Delete</flux:button></form></div>@empty<p class="py-4 text-sm text-neutral-500">No reports configured.</p>@endforelse</div></div>@endcan
</div>
</x-layouts.app>
+4
View File
@@ -0,0 +1,4 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Analytics\Http\Controllers\TrackingController;
Route::post('/analytics/events',[TrackingController::class,'store'])->middleware('throttle:60,1')->name('api.analytics.events.store');
+8
View File
@@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Analytics\Http\Controllers\AnalyticsController;
Route::middleware(['auth','permission:analytics.view'])->prefix('administration/analytics')->name('administration.analytics.')->group(function () {
Route::get('/',[AnalyticsController::class,'index'])->name('index');
Route::post('/reports',[AnalyticsController::class,'storeReport'])->middleware('permission:analytics.manage')->name('reports.store');
Route::delete('/reports/{report}',[AnalyticsController::class,'destroyReport'])->middleware('permission:analytics.manage')->name('reports.destroy');
});
@@ -0,0 +1,27 @@
<?php
namespace Modules\Analytics\Services;
use Illuminate\Support\Facades\DB;
use Modules\Analytics\Models\AnalyticsEvent;
use Modules\Analytics\Models\DailyMetric;
final class AnalyticsAggregator
{
public function aggregate(?string $date=null): int
{
$date=$date ?: now()->subHour()->toDateString(); $written=0;
$base=AnalyticsEvent::query()->whereDate('occurred_at',$date);
foreach ($base->clone()->select('site_id')->distinct()->pluck('site_id') as $siteId) {
$query=$base->clone()->where('site_id',$siteId);
$metrics=[
['page_views','all',$query->clone()->where('event_type','page_view')->count()],
['visitors','all',$query->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash')],
['sessions','all',$query->clone()->whereNotNull('session_hash')->distinct('session_hash')->count('session_hash')],
['conversions','all',$query->clone()->where('event_type','conversion')->count()],
['searches','all',$query->clone()->where('event_type','search')->count()],
];
foreach ($metrics as [$metric,$dimension,$value]) {
DailyMetric::updateOrCreate(['site_id'=>$siteId,'metric_date'=>$date,'metric'=>$metric,'dimension'=>$dimension],['value'=>$value]); $written++;
}
}
return $written;
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Analytics\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Analytics\Models\AnalyticsEvent;
final class AnalyticsTracker
{
public function track(Request $request, array $payload): ?AnalyticsEvent
{
if (!config('analytics.enabled')) return null;
if (config('analytics.respect_dnt') && $request->header('DNT') === '1') return null;
$path=ltrim((string)($payload['path'] ?? ''), '/');
foreach (config('analytics.exclude_paths',[]) as $excluded) if ($excluded !== '' && str_starts_with($path, trim($excluded,'/'))) return null;
$ip=(string)$request->ip();
return AnalyticsEvent::create([
'event_id'=>(string)Str::uuid(), 'site_id'=>app()->bound('currentSite') ? app('currentSite')?->id : null,
'event_type'=>$payload['event_type'] ?? 'page_view', 'path'=>'/'.$path, 'title'=>$payload['title'] ?? null,
'referrer'=>$payload['referrer'] ?? $request->headers->get('referer'),
'visitor_hash'=>hash('sha256', (string)($payload['visitor_id'] ?? $request->cookie('analytics_visitor','guest'))),
'session_hash'=>hash('sha256', (string)($payload['session_id'] ?? $request->session()->getId())),
'ip_hash'=>config('analytics.anonymize_ip') ? hash('sha256',$ip.config('app.key')) : $ip,
'device_type'=>$payload['device_type'] ?? null, 'browser'=>$payload['browser'] ?? null,
'operating_system'=>$payload['operating_system'] ?? null, 'subject_type'=>$payload['subject_type'] ?? null,
'subject_id'=>$payload['subject_id'] ?? null, 'properties'=>$payload['properties'] ?? [], 'occurred_at'=>now(),
]);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Analytics\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class AnalyticsModuleTest extends TestCase
{
use RefreshDatabase;
public function test_tracking_endpoint_records_page_view(): void
{
$this->postJson('/api/v1/analytics/events',['event_type'=>'page_view','path'=>'/about'])->assertCreated()->assertJson(['recorded'=>true]);
$this->assertDatabaseHas('analytics_events',['event_type'=>'page_view','path'=>'/about']);
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"Analytics","alias":"analytics","version":"1.0.0","description":"Privacy-conscious visitor, content, search, conversion and report analytics.","keywords":["analytics","reports","metrics","visitors","conversions"],"priority":150,"providers":["Modules\\Analytics\\Providers\\AnalyticsServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Sites","Search","Forms","Newsletter","Commerce"]}
@@ -76,6 +76,9 @@ final class RolePermissionSeeder extends Seeder
'ai.view',
'ai.manage',
'ai.execute',
'analytics.view',
'analytics.manage',
'analytics.export',
];
foreach ($permissions as $permission) {
+2 -1
View File
@@ -40,5 +40,6 @@
"Commerce": true,
"Security": true,
"Automation": true,
"AI": true
"AI": true,
"Analytics": true
}