wappomic/laravel-analytics

Cookie-free GDPR-compliant analytics package for Laravel - API-based without database

v1.0.4 2025-08-10 12:25 UTC

This package is auto-updated.

Last update: 2025-08-12 16:03:25 UTC


README

en de

Cookie-free, GDPR-compliant analytics package for Laravel

Collects anonymized website data and sends it to your own API. No cookies, no banner - just install and go.

🎯 Features

  • πŸͺ Cookie-free - No consent required
  • πŸ”’ GDPR-compliant - Immediate anonymization of all data
  • 🌐 API-based - Sends data to your own analytics API
  • ⚑ Performance - < 2ms overhead, asynchronous processing
  • πŸŽ›οΈ Multi-App Support - One dashboard for multiple apps/websites
  • πŸ”§ Plug & Play - Automatic tracking after installation
  • πŸ”„ Session Tracking - Cookie-free unique visitor identification

πŸ“¦ Installation

composer require wappomic/laravel-analytics
php artisan vendor:publish --tag=analytics-config

.env Configuration

# REQUIRED
ANALYTICS_API_URL=https://your-dashboard.com/api/analytics
ANALYTICS_API_KEY=your-unique-app-key-12345

# OPTIONAL  
ANALYTICS_APP_NAME="My Laravel Shop"
ANALYTICS_ENABLED=true
ANALYTICS_QUEUE_ENABLED=true
ANALYTICS_QUEUE_CONNECTION=redis
ANALYTICS_QUEUE_NAME=analytics
ANALYTICS_SESSION_TRACKING_ENABLED=true
ANALYTICS_SESSION_TTL_HOURS=24
ANALYTICS_VERBOSE_LOGGING=0  # Use 1 for true, 0 for false

That's it! πŸŽ‰ The package now automatically tracks all web requests.

πŸ“ Changelog

All notable changes to this project are documented in the CHANGELOG.md.

πŸ†• Current Version: v1.0.4

  • πŸ›‘οΈ Production Hardening: Request deduplication prevents 3x tracking issues
  • πŸ“Š Enhanced Diagnostics: Detailed API error logging with HTTP response details
  • πŸ”§ Smart Filtering: Load balancer health check detection and internal request filtering
  • πŸ“ Flexible Logging: Optional verbose logging with ANALYTICS_VERBOSE_LOGGING config

β†’ View Full Changelog

🚫 Excluded Routes Configuration

By default, certain routes are automatically excluded from tracking (admin pages, APIs, static files, etc.). You can customize this list or disable exclusions entirely.

Default Excluded Routes

// config/analytics.php
'excluded_routes' => [
    '/admin*',
    '/api*',
    '/broadcasting*',  // 🎯 Solves Laravel Broadcasting auth issues
    '/health*',
    '/robots.txt',
    '/sitemap.xml',
    '*.json',
    '*.xml',
    '*.css',
    '*.js',
    '*.ico',
    '*.png',
    '*.jpg',
    '*.jpeg',
    '*.gif',
    '*.svg',
    '*.woff*',
    '*.ttf',
],

Customization Examples

// Track everything (no exclusions)
'excluded_routes' => [],

// Only exclude specific routes
'excluded_routes' => ['/admin*', '/broadcasting*'],

// Add your custom routes to defaults
'excluded_routes' => [
    '/admin*',
    '/api*',
    '/broadcasting*',
    '/health*',
    '/robots.txt',
    '/sitemap.xml',
    '*.json',
    '*.xml',
    '*.css',
    '*.js',
    '*.ico',
    '*.png',
    '*.jpg',
    '*.jpeg',
    '*.gif',
    '*.svg',
    '*.woff*',
    '*.ttf',
    '/my-private-section*',  // Your custom exclusions
    '/internal-api/*',
    '*.pdf',
],

Wildcard Patterns

The excluded routes support wildcard patterns using fnmatch():

Pattern Matches Examples
/admin* Routes starting with /admin /admin, /admin/users, /admin/dashboard/settings
*.json Routes ending with .json /data.json, /api/users.json
/api/* Routes under /api/ /api/users, /api/v1/posts
*broadcasting* Routes containing broadcasting /broadcasting/auth, /laravel/broadcasting/auth

Common Use Cases

Laravel Broadcasting (WebSocket Auth):

'excluded_routes' => ['/broadcasting*'],
// Prevents tracking of '/broadcasting/auth' routes

SPA Applications:

'excluded_routes' => ['/api*', '*.json'],
// Only track page views, not API calls

Track Everything:

'excluded_routes' => [],
// No automatic exclusions - track all routes

Combined with Route Middleware

You have two options to exclude routes:

// Option 1: Global config (recommended)
'excluded_routes' => ['/admin*', '/broadcasting*'],

// Option 2: Per-route basis
Route::get('/admin', AdminController::class)->withoutMiddleware('analytics.tracking');

πŸ“Š Data Format

Your API receives POST requests with this JSON payload:

{
  "api_key": "your-unique-app-key-12345",
  "app_name": "My Laravel Shop",
  "timestamp": "2025-08-04T14:00:00Z",
  "url": "/products/laptop",
  "referrer": "https://google.com",
  "anonymized_ip": "192.168.1.0",
  "browser": "Chrome", 
  "device": "desktop",
  "country": "DE",
  "session_hash": "abc123def456789abcdef123456789abc",
  "is_new_session": true,
  "pageview_count": 1,
  "session_duration": 0,
  "custom_data": null
}

πŸ”‘ Multi-App Setup (Recommended)

One dashboard for all your apps:

# App 1: Online Shop
ANALYTICS_API_KEY=shop-key-abc123
ANALYTICS_APP_NAME="Online Shop"

# App 2: Blog  
ANALYTICS_API_KEY=blog-key-def456
ANALYTICS_APP_NAME="Tech Blog"

# App 3: Landing Page
ANALYTICS_API_KEY=landing-key-ghi789
ANALYTICS_APP_NAME="Product Landing"

All apps send to the same ANALYTICS_API_URL but with different api_key - perfect data separation.

πŸ› οΈ Analytics Dashboard Implementation

1. Create API Endpoint

// routes/api.php
Route::post('/analytics', [AnalyticsController::class, 'store']);
// app/Http/Controllers/AnalyticsController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\AnalyticsData;
use App\Models\App;

class AnalyticsController extends Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'api_key' => 'required|string',
            'app_name' => 'nullable|string',
            'timestamp' => 'required|date',
            'url' => 'required|string',
            'referrer' => 'nullable|string',
            'anonymized_ip' => 'required|string',
            'browser' => 'nullable|string',
            'device' => 'nullable|string',
            'country' => 'nullable|string|size:2',
            'session_hash' => 'nullable|string',
            'is_new_session' => 'nullable|boolean',
            'pageview_count' => 'nullable|integer',
            'session_duration' => 'nullable|integer',
            'custom_data' => 'nullable|array',
        ]);

        // Find app by API key
        $app = App::where('api_key', $data['api_key'])->first();
        
        if (!$app) {
            return response()->json(['error' => 'Invalid API key'], 401);
        }

        // Store analytics data
        AnalyticsData::create([
            'app_id' => $app->id,
            'timestamp' => $data['timestamp'],
            'url' => $data['url'],
            'referrer' => $data['referrer'],
            'anonymized_ip' => $data['anonymized_ip'],
            'browser' => $data['browser'],
            'device' => $data['device'],
            'country' => $data['country'],
            'session_hash' => $data['session_hash'] ?? null,
            'is_new_session' => $data['is_new_session'] ?? false,
            'pageview_count' => $data['pageview_count'] ?? 1,
            'session_duration' => $data['session_duration'] ?? 0,
            'custom_data' => $data['custom_data'],
        ]);

        // Update app name on first request (optional)
        if ($data['app_name'] && $app->name !== $data['app_name']) {
            $app->update(['name' => $data['app_name']]);
        }

        return response()->json(['status' => 'success']);
    }
}

2. Database Schema

// Migration: create_apps_table.php
Schema::create('apps', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('api_key')->unique();
    $table->string('domain')->nullable();
    $table->timestamps();
});

// Migration: create_analytics_data_table.php  
Schema::create('analytics_data', function (Blueprint $table) {
    $table->id();
    $table->foreignId('app_id')->constrained()->onDelete('cascade');
    $table->timestamp('timestamp');
    $table->string('url');
    $table->string('referrer')->nullable();
    $table->string('anonymized_ip');
    $table->string('browser')->nullable();
    $table->string('device')->nullable();
    $table->string('country', 2)->nullable();
    $table->string('session_hash', 64)->nullable();
    $table->boolean('is_new_session')->default(false);
    $table->integer('pageview_count')->default(1);
    $table->integer('session_duration')->default(0);
    $table->json('custom_data')->nullable();
    $table->timestamps();
    
    $table->index(['app_id', 'timestamp']);
    $table->index(['app_id', 'url']);
    $table->index(['app_id', 'session_hash']);
    $table->index(['app_id', 'is_new_session']);
});

3. Models

// app/Models/App.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class App extends Model
{
    protected $fillable = ['name', 'api_key', 'domain'];
    
    public function analyticsData()
    {
        return $this->hasMany(AnalyticsData::class);
    }
    
    protected static function boot()
    {
        parent::boot();
        
        static::creating(function ($app) {
            if (!$app->api_key) {
                $app->api_key = 'app-' . Str::random(20);
            }
        });
    }
}

// app/Models/AnalyticsData.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class AnalyticsData extends Model
{
    protected $fillable = [
        'app_id', 'timestamp', 'url', 'referrer', 
        'anonymized_ip', 'browser', 'device', 'country', 
        'session_hash', 'is_new_session', 'pageview_count', 'session_duration',
        'custom_data'
    ];
    
    protected $casts = [
        'timestamp' => 'datetime',
        'is_new_session' => 'boolean',
        'pageview_count' => 'integer',
        'session_duration' => 'integer',
        'custom_data' => 'array',
    ];
    
    public function app()
    {
        return $this->belongsTo(App::class);
    }
}

4. Dashboard Controller

// app/Http/Controllers/DashboardController.php
<?php

namespace App\Http\Controllers;

use App\Models\App;

class DashboardController extends Controller
{
    public function index()
    {
        $apps = App::withCount('analyticsData')->get();
        
        return view('dashboard.index', compact('apps'));
    }
    
    public function app(App $app)
    {
        $stats = [
            'total_pageviews' => $app->analyticsData()->count(),
            'unique_visitors' => $app->analyticsData()->where('is_new_session', true)->count(),
            'today_pageviews' => $app->analyticsData()->whereDate('timestamp', today())->count(),
            'today_visitors' => $app->analyticsData()
                ->whereDate('timestamp', today())
                ->where('is_new_session', true)
                ->count(),
            'top_pages' => $app->analyticsData()
                ->select('url')
                ->selectRaw('COUNT(*) as pageviews')
                ->selectRaw('COUNT(CASE WHEN is_new_session = 1 THEN 1 END) as unique_visitors')
                ->groupBy('url')
                ->orderByDesc('pageviews')
                ->limit(10)
                ->get(),
            'countries' => $app->analyticsData()
                ->select('country')
                ->selectRaw('COUNT(CASE WHEN is_new_session = 1 THEN 1 END) as unique_visitors')
                ->selectRaw('COUNT(*) as pageviews')
                ->whereNotNull('country')
                ->groupBy('country')
                ->orderByDesc('unique_visitors')
                ->limit(10)
                ->get(),
            'avg_session_duration' => $app->analyticsData()
                ->where('session_duration', '>', 0)
                ->avg('session_duration'),
        ];
        
        return view('dashboard.app', compact('app', 'stats'));
    }
}

πŸ” GDPR Compliance

βœ… Why no consent is required:

  • No cookies - Package sets no cookies
  • Immediate anonymization - IP becomes 192.168.1.0
  • No user tracking - No persistent user identification
  • Data minimization - Only necessary data
  • Legitimate interest - Art. 6 Para. 1 lit. f GDPR
  • Session hashing - Anonymous daily session hashes, not traceable

πŸ›‘οΈ Anonymization:

Original Anonymized
192.168.1.123 192.168.1.0
Mozilla/5.0 Chrome/91.0... Chrome
2025-08-04 14:23:45 2025-08-04 14:00:00
Munich, Bavaria DE
Session ID abc123def456... (daily hash)

βš™οΈ Advanced Usage

Manual Tracking

use Wappomic\Analytics\Facades\Analytics;

// Track custom event
Analytics::track([
    'url' => '/newsletter-signup',
    'custom_data' => ['campaign' => 'summer-sale']
]);

// Check status
if (Analytics::isEnabled() && Analytics::isConfigured()) {
    // Analytics is running
}

// Test API connection
if (Analytics::testConnection()) {
    echo "βœ… API reachable";
} else {
    echo "❌ API problem - check config";
}

Manual Middleware Control

// routes/web.php

// Automatic tracking for all routes (default)
Route::get('/', HomeController::class);

// Disable tracking for specific routes
Route::get('/admin', AdminController::class)->withoutMiddleware('analytics.tracking');

// Track only specific routes
Route::group(['middleware' => 'analytics.tracking'], function () {
    Route::get('/shop', ShopController::class);
    Route::get('/products', ProductController::class);
});

πŸš€ Performance & Monitoring

  • Middleware overhead: < 2ms
  • Asynchronous: Via Laravel Queues (recommended)
  • Request deduplication: Prevents duplicate tracking with Redis cache
  • Smart filtering: Load balancer health checks and internal requests
  • Retry logic: 2x retry with exponential backoff (5s, 15s)
  • Timeout: 10 seconds
  • Production logging: Clean logs with optional verbose debugging

Verbose Logging

For debugging, enable detailed logging:

ANALYTICS_VERBOSE_LOGGING=1
APP_DEBUG=true  # Required - verbose logging only works when Laravel debug is enabled

Debug logs include:

  • Request middleware triggers with headers
  • API request/response details
  • Queue job processing steps
  • Duplicate request detection
  • Performance metrics

Production logs (always enabled):

  • API failures with HTTP details
  • Configuration errors
  • Job completion status

πŸ”§ Troubleshooting

No data received?

  1. Check config:
php artisan tinker
>>> Analytics::validateConfig()
>>> Analytics::testConnection()
  1. Enable verbose logging:
ANALYTICS_VERBOSE_LOGGING=1
APP_DEBUG=true

Then check logs:

tail -f storage/logs/laravel.log | grep -i analytics
  1. Queue running?:
php artisan queue:work
# Or temporarily disable:
ANALYTICS_QUEUE_ENABLED=false

Duplicate tracking (3x same data)?

Solution: Enable verbose logging to see duplicate detection:

ANALYTICS_VERBOSE_LOGGING=1
APP_DEBUG=true

Look for Analytics duplicate request detected in logs. If still occurring:

  1. Check middleware registration - ensure only registered once
  2. Verify Redis cache - deduplication requires working cache
  3. Load balancer setup - health checks might bypass deduplication

Queue problems with Redis?

If you're using Redis and the queue isn't working:

  1. Check Redis connection:
php artisan tinker
>>> Redis::ping()  # Should return "PONG"
  1. Configure Redis queue explicitly:
ANALYTICS_QUEUE_CONNECTION=redis
ANALYTICS_QUEUE_NAME=analytics
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
  1. Check failed jobs:
php artisan queue:failed
php artisan queue:retry all  # Retry failed jobs
  1. Monitor queue in real-time:
php artisan queue:work --verbose --tries=3 --timeout=30
  1. Check logs:
tail -f storage/logs/laravel.log

API Debugging

// Your analytics API should return:
HTTP/1.1 200 OK
Content-Type: application/json

{"status": "success"}

// On errors:
HTTP/1.1 400 Bad Request
{"error": "Invalid data", "details": [...]}

πŸ“ˆ Next Steps

  1. Generate API keys for your apps
  2. Implement dashboard with examples above
  3. Add charts (Chart.js, ApexCharts)
  4. Real-time updates with WebSockets
  5. Export functions (PDF, Excel)

πŸ“„ License

MIT License - See LICENSE for details.

Happy Analytics! πŸŽ‰
Feel free to create an issue on GitHub if you have questions.

πŸ‡©πŸ‡ͺ Deutsche Version

Die vollstΓ€ndige deutsche Dokumentation finden Sie in der README.de.md Datei.

For the complete German documentation, please see README.de.md.