From 76e29fab03a48332d870768c6e263c2afd292404 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 13:06:48 +0100 Subject: [PATCH] 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 --- Modules/Analytics/Config/config.php | 9 ++++ .../Analytics/Console/AggregateAnalytics.php | 9 ++++ Modules/Analytics/Console/PruneAnalytics.php | 9 ++++ .../Analytics/Console/RunScheduledReports.php | 13 ++++++ ...6_07_24_150000_create_analytics_tables.php | 42 +++++++++++++++++++ .../Http/Controllers/AnalyticsController.php | 30 +++++++++++++ .../Http/Controllers/TrackingController.php | 21 ++++++++++ Modules/Analytics/Models/AnalyticsEvent.php | 8 ++++ Modules/Analytics/Models/AnalyticsReport.php | 8 ++++ .../Analytics/Models/AnalyticsReportRun.php | 8 ++++ Modules/Analytics/Models/DailyMetric.php | 9 ++++ .../Providers/AnalyticsServiceProvider.php | 28 +++++++++++++ .../Providers/RouteServiceProvider.php | 12 ++++++ .../Analytics/Resources/views/index.blade.php | 14 +++++++ Modules/Analytics/Routes/api.php | 4 ++ Modules/Analytics/Routes/web.php | 8 ++++ .../Services/AnalyticsAggregator.php | 27 ++++++++++++ .../Analytics/Services/AnalyticsTracker.php | 27 ++++++++++++ .../Tests/Feature/AnalyticsModuleTest.php | 13 ++++++ Modules/Analytics/module.json | 1 + .../Database/Seeders/RolePermissionSeeder.php | 3 ++ modules_statuses.json | 3 +- 22 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 Modules/Analytics/Config/config.php create mode 100644 Modules/Analytics/Console/AggregateAnalytics.php create mode 100644 Modules/Analytics/Console/PruneAnalytics.php create mode 100644 Modules/Analytics/Console/RunScheduledReports.php create mode 100644 Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php create mode 100644 Modules/Analytics/Http/Controllers/AnalyticsController.php create mode 100644 Modules/Analytics/Http/Controllers/TrackingController.php create mode 100644 Modules/Analytics/Models/AnalyticsEvent.php create mode 100644 Modules/Analytics/Models/AnalyticsReport.php create mode 100644 Modules/Analytics/Models/AnalyticsReportRun.php create mode 100644 Modules/Analytics/Models/DailyMetric.php create mode 100644 Modules/Analytics/Providers/AnalyticsServiceProvider.php create mode 100644 Modules/Analytics/Providers/RouteServiceProvider.php create mode 100644 Modules/Analytics/Resources/views/index.blade.php create mode 100644 Modules/Analytics/Routes/api.php create mode 100644 Modules/Analytics/Routes/web.php create mode 100644 Modules/Analytics/Services/AnalyticsAggregator.php create mode 100644 Modules/Analytics/Services/AnalyticsTracker.php create mode 100644 Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php create mode 100644 Modules/Analytics/module.json diff --git a/Modules/Analytics/Config/config.php b/Modules/Analytics/Config/config.php new file mode 100644 index 0000000..4984472 --- /dev/null +++ b/Modules/Analytics/Config/config.php @@ -0,0 +1,9 @@ + (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'), +]; diff --git a/Modules/Analytics/Console/AggregateAnalytics.php b/Modules/Analytics/Console/AggregateAnalytics.php new file mode 100644 index 0000000..99c45af --- /dev/null +++ b/Modules/Analytics/Console/AggregateAnalytics.php @@ -0,0 +1,9 @@ +info('Metrics written: '.$aggregator->aggregate($this->option('date'))); return self::SUCCESS; } +} diff --git a/Modules/Analytics/Console/PruneAnalytics.php b/Modules/Analytics/Console/PruneAnalytics.php new file mode 100644 index 0000000..c5bb5ac --- /dev/null +++ b/Modules/Analytics/Console/PruneAnalytics.php @@ -0,0 +1,9 @@ +subDays(config('analytics.retention_days',365)))->delete(); $this->info("Deleted {$count} events."); return self::SUCCESS; } +} diff --git a/Modules/Analytics/Console/RunScheduledReports.php b/Modules/Analytics/Console/RunScheduledReports.php new file mode 100644 index 0000000..c04bd1f --- /dev/null +++ b/Modules/Analytics/Console/RunScheduledReports.php @@ -0,0 +1,13 @@ +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; + } +} diff --git a/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php b/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php new file mode 100644 index 0000000..5326366 --- /dev/null +++ b/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/Modules/Analytics/Http/Controllers/AnalyticsController.php b/Modules/Analytics/Http/Controllers/AnalyticsController.php new file mode 100644 index 0000000..d2ffee9 --- /dev/null +++ b/Modules/Analytics/Http/Controllers/AnalyticsController.php @@ -0,0 +1,30 @@ +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.'); } +} diff --git a/Modules/Analytics/Http/Controllers/TrackingController.php b/Modules/Analytics/Http/Controllers/TrackingController.php new file mode 100644 index 0000000..31598f4 --- /dev/null +++ b/Modules/Analytics/Http/Controllers/TrackingController.php @@ -0,0 +1,21 @@ +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); + } +} diff --git a/Modules/Analytics/Models/AnalyticsEvent.php b/Modules/Analytics/Models/AnalyticsEvent.php new file mode 100644 index 0000000..578f089 --- /dev/null +++ b/Modules/Analytics/Models/AnalyticsEvent.php @@ -0,0 +1,8 @@ +'array','occurred_at'=>'datetime']; +} diff --git a/Modules/Analytics/Models/AnalyticsReport.php b/Modules/Analytics/Models/AnalyticsReport.php new file mode 100644 index 0000000..edbd14e --- /dev/null +++ b/Modules/Analytics/Models/AnalyticsReport.php @@ -0,0 +1,8 @@ +'array','columns'=>'array','recipients'=>'array','is_active'=>'boolean','last_run_at'=>'datetime','next_run_at'=>'datetime']; +} diff --git a/Modules/Analytics/Models/AnalyticsReportRun.php b/Modules/Analytics/Models/AnalyticsReportRun.php new file mode 100644 index 0000000..98829d6 --- /dev/null +++ b/Modules/Analytics/Models/AnalyticsReportRun.php @@ -0,0 +1,8 @@ +'datetime','completed_at'=>'datetime']; +} diff --git a/Modules/Analytics/Models/DailyMetric.php b/Modules/Analytics/Models/DailyMetric.php new file mode 100644 index 0000000..6f3ff47 --- /dev/null +++ b/Modules/Analytics/Models/DailyMetric.php @@ -0,0 +1,9 @@ +'date','metadata'=>'array']; +} diff --git a/Modules/Analytics/Providers/AnalyticsServiceProvider.php b/Modules/Analytics/Providers/AnalyticsServiceProvider.php new file mode 100644 index 0000000..1bacbfe --- /dev/null +++ b/Modules/Analytics/Providers/AnalyticsServiceProvider.php @@ -0,0 +1,28 @@ +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(); + }); + } + } +} diff --git a/Modules/Analytics/Providers/RouteServiceProvider.php b/Modules/Analytics/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c1a8eb3 --- /dev/null +++ b/Modules/Analytics/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Analytics', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Analytics', 'Routes/api.php')); + } +} diff --git a/Modules/Analytics/Resources/views/index.blade.php b/Modules/Analytics/Resources/views/index.blade.php new file mode 100644 index 0000000..66e7cd8 --- /dev/null +++ b/Modules/Analytics/Resources/views/index.blade.php @@ -0,0 +1,14 @@ + +
+
AnalyticsVisitor, content, search and conversion performance.
+ @if(session('status')){{ session('status') }}@endif +
+ @foreach([['Page views',$pageViews],['Visitors',$visitors],['Conversions',$conversions],['Searches',$searches]] as [$label,$value])
{{ $label }}
{{ number_format($value) }}
@endforeach +
+
+
Top pages
@forelse($topPages as $page)
{{ $page->path ?: '/' }}{{ number_format($page->total) }}
@empty

No page views recorded.

@endforelse
+
Recent events
@forelse($recentEvents as $event)
{{ str($event->event_type)->replace('_',' ')->title() }}{{ $event->occurred_at?->diffForHumans() }}

{{ $event->path ?: data_get($event->properties,'query','—') }}

@empty

No events recorded.

@endforelse
+
+ @can('analytics.manage')
Scheduled reports
@csrf
Create report
@forelse($reports as $report)
{{ $report->name }}

{{ ucfirst($report->report_type) }} · {{ strtoupper($report->format) }}

@csrf @method('DELETE')Delete
@empty

No reports configured.

@endforelse
@endcan +
+
diff --git a/Modules/Analytics/Routes/api.php b/Modules/Analytics/Routes/api.php new file mode 100644 index 0000000..7cc948c --- /dev/null +++ b/Modules/Analytics/Routes/api.php @@ -0,0 +1,4 @@ +middleware('throttle:60,1')->name('api.analytics.events.store'); diff --git a/Modules/Analytics/Routes/web.php b/Modules/Analytics/Routes/web.php new file mode 100644 index 0000000..287b0a3 --- /dev/null +++ b/Modules/Analytics/Routes/web.php @@ -0,0 +1,8 @@ +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'); +}); diff --git a/Modules/Analytics/Services/AnalyticsAggregator.php b/Modules/Analytics/Services/AnalyticsAggregator.php new file mode 100644 index 0000000..e6bb035 --- /dev/null +++ b/Modules/Analytics/Services/AnalyticsAggregator.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/Modules/Analytics/Services/AnalyticsTracker.php b/Modules/Analytics/Services/AnalyticsTracker.php new file mode 100644 index 0000000..c88ee57 --- /dev/null +++ b/Modules/Analytics/Services/AnalyticsTracker.php @@ -0,0 +1,27 @@ +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(), + ]); + } +} diff --git a/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php b/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php new file mode 100644 index 0000000..c1217a4 --- /dev/null +++ b/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php @@ -0,0 +1,13 @@ +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']); + } +} diff --git a/Modules/Analytics/module.json b/Modules/Analytics/module.json new file mode 100644 index 0000000..7afb408 --- /dev/null +++ b/Modules/Analytics/module.json @@ -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"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 6195898..eb903e7 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -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) { diff --git a/modules_statuses.json b/modules_statuses.json index 11b3f32..16d0ae6 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -40,5 +40,6 @@ "Commerce": true, "Security": true, "Automation": true, - "AI": true + "AI": true, + "Analytics": true }