style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'enabled' => (bool) env('ANALYTICS_ENABLED', true),
'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true),
@@ -1,9 +1,20 @@
<?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; }
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;
}
}
+14 -2
View File
@@ -1,9 +1,21 @@
<?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; }
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;
}
}
@@ -1,13 +1,21 @@
<?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.';
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;
$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;
}
}
@@ -1,42 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
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']);
$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');
$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();
$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();
$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');
Schema::dropIfExists('analytics_report_runs');
Schema::dropIfExists('analytics_reports');
Schema::dropIfExists('analytics_daily_metrics');
Schema::dropIfExists('analytics_events');
}
};
@@ -1,30 +1,43 @@
<?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(),
$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.');
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.');
}
public function destroyReport(AnalyticsReport $report): RedirectResponse { $report->delete(); return back()->with('status','Report deleted.'); }
}
@@ -1,21 +1,25 @@
<?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'],
$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);
$event = $tracker->track($request, $data);
return response()->json(['recorded' => (bool) $event], $event ? 201 : 202);
}
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?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'];
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'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?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'];
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'];
}
@@ -1,8 +1,12 @@
<?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'];
protected $fillable = ['analytics_report_id', 'status', 'disk', 'path', 'row_count', 'error', 'started_at', 'completed_at'];
protected $casts = ['started_at' => 'datetime', 'completed_at' => 'datetime'];
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?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'];
protected $table = 'analytics_daily_metrics';
protected $fillable = ['site_id', 'metric_date', 'metric', 'dimension', 'value', 'metadata'];
protected $casts = ['metric_date' => 'date', 'metadata' => 'array'];
}
@@ -1,10 +1,13 @@
<?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
@@ -12,6 +15,7 @@ final class AnalyticsServiceProvider extends ServiceProvider
$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'));
@@ -1,7 +1,10 @@
<?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
+3 -1
View File
@@ -1,4 +1,6 @@
<?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');
Route::post('/analytics/events', [TrackingController::class, 'store'])->middleware('throttle:60,1')->name('api.analytics.events.store');
+6 -4
View File
@@ -1,8 +1,10 @@
<?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');
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');
});
@@ -1,27 +1,32 @@
<?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
public function aggregate(?string $date = null): int
{
$date=$date ?: now()->subHour()->toDateString(); $written=0;
$base=AnalyticsEvent::query()->whereDate('occurred_at',$date);
$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()],
$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++;
DailyMetric::updateOrCreate(['site_id' => $siteId, 'metric_date' => $date, 'metric' => $metric, 'dimension' => $dimension], ['value' => $value]);
$written++;
}
}
return $written;
}
}
@@ -1,13 +1,17 @@
<?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']);
$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']);
}
}