move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
<?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'),
];
+20
View File
@@ -0,0 +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;
}
}
+21
View File
@@ -0,0 +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;
}
}
+21
View File
@@ -0,0 +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.';
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,80 @@
<?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,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(),
]);
}
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,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'],
]);
$event = $tracker->track($request, $data);
return response()->json(['recorded' => (bool) $event], $event ? 201 : 202);
}
}
+12
View File
@@ -0,0 +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'];
}
+12
View File
@@ -0,0 +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'];
}
+12
View File
@@ -0,0 +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'];
}
+14
View File
@@ -0,0 +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'];
}
@@ -0,0 +1,32 @@
<?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,15 @@
<?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'));
}
}
+14
View File
@@ -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>
+6
View File
@@ -0,0 +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');
+10
View File
@@ -0,0 +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');
});
@@ -0,0 +1,32 @@
<?php
namespace Modules\Analytics\Services;
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;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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();
$visitorIdentifier = (string) ($payload['visitor_id'] ?? $request->cookie('analytics_visitor', 'guest'));
$sessionIdentifier = (string) ($payload['session_id'] ?? $this->sessionIdentifier($request, $visitorIdentifier));
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', $visitorIdentifier),
'session_hash' => hash('sha256', $sessionIdentifier),
'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(),
]);
}
private function sessionIdentifier(Request $request, string $visitorIdentifier): string
{
if ($request->hasSession()) {
return $request->session()->getId();
}
return (string) ($request->header('X-Analytics-Session') ?: $visitorIdentifier ?: 'stateless');
}
}
@@ -0,0 +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']);
}
}
+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"]}