Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5a7df6ea | ||
|
|
1a7189cc32 | ||
|
|
ea68fa7767 | ||
|
|
6a0e9f7463 | ||
|
|
b08b1d8620 | ||
|
|
edb9cec503 | ||
|
|
02d7f07b29 | ||
|
|
d5f7793337 | ||
|
|
8a5aef0b13 | ||
|
|
b5f948433b | ||
|
|
4db544bcc2 | ||
|
|
671386c035 | ||
|
|
ce8c95880a | ||
|
|
908ea1c868 | ||
|
|
c7df826f74 | ||
|
|
d9942e135a | ||
|
|
3f83a258c1 | ||
|
|
7b4c6e59ba | ||
|
|
b38c2e3787 | ||
|
|
2e1d2fc428 | ||
|
|
bfc13c022d | ||
|
|
49b1d3581a | ||
|
|
82ebb697f3 | ||
|
|
d75cabfeec | ||
|
|
4390d43acb | ||
|
|
9fb575ae70 | ||
|
|
274f6a378e | ||
|
|
6ad100d331 | ||
|
|
a4383337f4 | ||
|
|
0cdf5b7082 | ||
|
|
7e42b8148d | ||
|
|
d2b95188d7 | ||
|
|
18f43d0891 |
-65
@@ -1,65 +0,0 @@
|
|||||||
APP_NAME=LibreMetrics
|
|
||||||
APP_ENV=local
|
|
||||||
APP_KEY=base64:ci0Tu2YBYEhrWTd82LEq2DxeVBmftfTuR6jUVqPOEKE=
|
|
||||||
APP_DEBUG=true
|
|
||||||
APP_URL=http://libremetrics.com
|
|
||||||
|
|
||||||
APP_LOCALE=en
|
|
||||||
APP_FALLBACK_LOCALE=en
|
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
|
||||||
# APP_MAINTENANCE_STORE=database
|
|
||||||
|
|
||||||
PHP_CLI_SERVER_WORKERS=4
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
|
||||||
LOG_STACK=single
|
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
|
||||||
DB_HOST=mysql
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_DATABASE=laravel
|
|
||||||
DB_USERNAME=sail
|
|
||||||
DB_PASSWORD=password
|
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
|
|
||||||
CACHE_STORE=database
|
|
||||||
# CACHE_PREFIX=
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
|
||||||
MAIL_SCHEME=null
|
|
||||||
MAIL_HOST=127.0.0.1
|
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: mbstring, sqlite3, pdo_sqlite
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Copy environment file
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate application key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: php artisan test --parallel
|
||||||
|
env:
|
||||||
|
DB_CONNECTION: sqlite
|
||||||
|
DB_DATABASE: ':memory:'
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Pint (Code Style)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Run Pint
|
||||||
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
|
static-analysis:
|
||||||
|
name: Larastan (Static Analysis)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Run Larastan
|
||||||
|
run: vendor/bin/phpstan analyse --memory-limit=512M
|
||||||
@@ -0,0 +1,939 @@
|
|||||||
|
# Analytics Platform - HOWTO Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [What is This Project?](#what-is-this-project)
|
||||||
|
2. [Features Overview](#features-overview)
|
||||||
|
3. [Architecture](#architecture)
|
||||||
|
4. [Installation](#installation)
|
||||||
|
5. [Configuration](#configuration)
|
||||||
|
6. [Required Background Tasks](#required-background-tasks)
|
||||||
|
7. [Tracking Setup](#tracking-setup)
|
||||||
|
8. [How It Works](#how-it-works)
|
||||||
|
9. [Database Structure](#database-structure)
|
||||||
|
10. [Artisan Commands](#artisan-commands)
|
||||||
|
11. [API Documentation](#api-documentation)
|
||||||
|
12. [Maintenance](#maintenance)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is This Project?
|
||||||
|
|
||||||
|
This is a **self-hosted web analytics platform** built with Laravel 11. It provides privacy-focused website analytics similar to Google Analytics or Plausible, allowing you to track visitor behavior, conversions, and engagement on your websites without relying on third-party services.
|
||||||
|
|
||||||
|
### Key Capabilities
|
||||||
|
- Real-time visitor tracking
|
||||||
|
- Goal and conversion tracking
|
||||||
|
- Custom event tracking
|
||||||
|
- Funnel analysis
|
||||||
|
- Automated alerts and notifications
|
||||||
|
- Custom dashboards
|
||||||
|
- Export analytics data
|
||||||
|
- API access for integrations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features Overview
|
||||||
|
|
||||||
|
### 1. **Visitor Tracking**
|
||||||
|
- Automatic page view tracking
|
||||||
|
- Session management (30-minute timeout)
|
||||||
|
- Device, browser, and OS detection
|
||||||
|
- Geolocation (country, region, city)
|
||||||
|
- Bot filtering
|
||||||
|
- UTM parameter tracking (campaign, source, medium, etc.)
|
||||||
|
- Time on page measurement
|
||||||
|
|
||||||
|
### 2. **Analytics Reports**
|
||||||
|
- **Audience**: Geographic distribution, technology breakdown, languages
|
||||||
|
- **Acquisition**: Traffic sources, referrers, campaigns
|
||||||
|
- **Behavior**: Popular pages, landing pages, exit pages
|
||||||
|
- **Events**: Custom event tracking with categories and labels
|
||||||
|
- **Conversions**: Goal completions with revenue tracking
|
||||||
|
|
||||||
|
### 3. **Goals (4 Types)**
|
||||||
|
- **URL Destination**: Track when users visit specific pages
|
||||||
|
- **Event Match**: Track custom events
|
||||||
|
- **Duration**: Sessions longer than X seconds
|
||||||
|
- **Pages Per Session**: Sessions with X or more pages
|
||||||
|
|
||||||
|
### 4. **Advanced Features**
|
||||||
|
- **Funnels**: Multi-step conversion tracking
|
||||||
|
- **Segments**: Custom visitor segments for filtered reporting
|
||||||
|
- **Alerts**: Automated notifications when metrics exceed thresholds
|
||||||
|
- **Annotations**: Add notes to specific dates for context
|
||||||
|
- **Custom Dashboards**: Drag-and-drop widget management
|
||||||
|
|
||||||
|
### 5. **Data Management**
|
||||||
|
- Automatic daily aggregation for performance
|
||||||
|
- Configurable data retention (default: 365 days)
|
||||||
|
- CSV export
|
||||||
|
- API access with token authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
- **Backend**: Laravel 11, PHP 8.2+
|
||||||
|
- **Frontend**: Blade templates, Alpine.js, Tailwind CSS
|
||||||
|
- **Database**: SQLite (default), MySQL/PostgreSQL compatible
|
||||||
|
- **Queue System**: Database-backed (Redis/SQS optional)
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Website (tracking script)
|
||||||
|
↓ POST /api/collect
|
||||||
|
CollectController (validates & queues)
|
||||||
|
↓ Queue Job
|
||||||
|
ProcessTrackingPayload (processes data)
|
||||||
|
↓ Stores in DB
|
||||||
|
Raw Tables (visitors, sessions, page_views, events)
|
||||||
|
↓ Daily aggregation (02:00 AM)
|
||||||
|
Aggregated Tables (daily_aggregates, page_daily_aggregates)
|
||||||
|
↓ Used by
|
||||||
|
Dashboard & Reports (hybrid: aggregates + real-time)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
cd /path/to/analytics
|
||||||
|
|
||||||
|
# Install dependencies and setup
|
||||||
|
composer setup
|
||||||
|
|
||||||
|
# This runs:
|
||||||
|
# - composer install
|
||||||
|
# - Creates .env from .env.example
|
||||||
|
# - php artisan key:generate
|
||||||
|
# - php artisan migrate --force
|
||||||
|
# - npm install
|
||||||
|
# - npm run build
|
||||||
|
|
||||||
|
# Start development environment (runs 4 processes)
|
||||||
|
composer run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- Web server: `http://localhost:8000`
|
||||||
|
- Queue worker (processes tracking data)
|
||||||
|
- Log viewer (Pail)
|
||||||
|
- Vite dev server (hot module reload)
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
# Generate application key
|
||||||
|
php artisan key:generate
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
php artisan migrate --force
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
chmod -R 755 storage bootstrap/cache
|
||||||
|
chown -R www-data:www-data storage bootstrap/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Application
|
||||||
|
APP_NAME="Analytics Platform"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://yourdomain.com
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=analytics
|
||||||
|
DB_USERNAME=your_user
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
|
||||||
|
# Queue (use 'redis' for better performance)
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
# Mail (for alerts and notifications)
|
||||||
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=smtp.mailtrap.io
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS=noreply@yourdomain.com
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Cache (use 'redis' for better performance)
|
||||||
|
CACHE_STORE=database
|
||||||
|
|
||||||
|
# Session
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Server Configuration
|
||||||
|
|
||||||
|
#### For Standard Setup (public/ folder)
|
||||||
|
Point your web server document root to the `public/` directory.
|
||||||
|
|
||||||
|
#### For Shared Hosting (engine/ + public_html/)
|
||||||
|
|
||||||
|
If your hosting has separate folders:
|
||||||
|
- `engine/` - Laravel application files
|
||||||
|
- `public_html/` - Public web root
|
||||||
|
|
||||||
|
**Modify `public_html/index.php`:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Point to engine directory
|
||||||
|
if (file_exists($maintenance = __DIR__.'/../engine/storage/framework/maintenance.php')) {
|
||||||
|
require $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__.'/../engine/vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = require_once __DIR__.'/../engine/bootstrap/app.php';
|
||||||
|
|
||||||
|
// Tell Laravel the public path is public_html
|
||||||
|
$app->usePublicPath(__DIR__);
|
||||||
|
|
||||||
|
$app->handleRequest(Request::capture());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Background Tasks
|
||||||
|
|
||||||
|
### 1. Queue Worker (CRITICAL)
|
||||||
|
|
||||||
|
**Purpose**: Processes tracking data from websites
|
||||||
|
|
||||||
|
Without this, tracking events are queued but never persisted to the database.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start queue worker
|
||||||
|
php artisan queue:work database --tries=3 --timeout=60
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production Setup**: Use a process supervisor
|
||||||
|
|
||||||
|
**Using Supervisor** (recommended):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/supervisor/conf.d/analytics-queue.conf
|
||||||
|
[program:analytics-queue]
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
|
command=php /path/to/analytics/artisan queue:work database --tries=3 --timeout=60
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
user=www-data
|
||||||
|
numprocs=1
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/path/to/analytics/storage/logs/queue-worker.log
|
||||||
|
stopwaitsecs=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
```bash
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update
|
||||||
|
sudo supervisorctl start analytics-queue:*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using systemd**:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/analytics-queue.service
|
||||||
|
[Unit]
|
||||||
|
Description=Analytics Queue Worker
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/path/to/analytics
|
||||||
|
ExecStart=/usr/bin/php /path/to/analytics/artisan queue:work database --tries=3 --timeout=60
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable analytics-queue
|
||||||
|
sudo systemctl start analytics-queue
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Task Scheduler (CRITICAL)
|
||||||
|
|
||||||
|
**Purpose**: Runs scheduled tasks (aggregation, purging, alerts)
|
||||||
|
|
||||||
|
Add to crontab:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit crontab
|
||||||
|
crontab -e
|
||||||
|
|
||||||
|
# Add this line
|
||||||
|
* * * * * cd /path/to/analytics && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
This executes:
|
||||||
|
- **Daily at 02:00 AM**: `analytics:aggregate` - Rolls up data into daily aggregates
|
||||||
|
- **Daily at 03:00 AM**: `analytics:purge --force` - Deletes old raw data
|
||||||
|
- **Every hour**: `analytics:check-alerts` - Evaluates alerts and sends notifications
|
||||||
|
|
||||||
|
Without the scheduler:
|
||||||
|
- Reports will be slower (no aggregated data)
|
||||||
|
- Old data will accumulate and bloat the database
|
||||||
|
- Alerts will never trigger
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking Setup
|
||||||
|
|
||||||
|
### 1. Create a Website
|
||||||
|
|
||||||
|
1. Register/login to the analytics platform
|
||||||
|
2. Navigate to **Websites** → **Add Website**
|
||||||
|
3. Enter website name and domain
|
||||||
|
4. Copy the tracking ID (e.g., `abc123def456`)
|
||||||
|
|
||||||
|
### 2. Install Tracking Script
|
||||||
|
|
||||||
|
Add this script to your website's HTML (before `</head>`):
|
||||||
|
|
||||||
|
**Option 1: JavaScript Snippet**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script async src="https://analytics.yourdomain.com/api/t/YOUR_TRACKING_ID.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Self-hosted Script**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
!function(t,e,a){/* tracking script */}(window,document,"https://analytics.yourdomain.com","YOUR_TRACKING_ID");
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: No-JavaScript Fallback**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<noscript>
|
||||||
|
<img src="https://analytics.yourdomain.com/api/collect/pixel/YOUR_TRACKING_ID"
|
||||||
|
alt=""
|
||||||
|
style="position:absolute;left:-9999px;" />
|
||||||
|
</noscript>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Track Custom Events
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Track a custom event
|
||||||
|
ta('event', 'button_click', {
|
||||||
|
category: 'engagement',
|
||||||
|
label: 'signup_button',
|
||||||
|
value: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track a purchase
|
||||||
|
ta('event', 'purchase', {
|
||||||
|
category: 'ecommerce',
|
||||||
|
value: 99.99
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. SPA Support
|
||||||
|
|
||||||
|
For single-page applications (React, Vue, etc.), the tracker automatically detects route changes via `pushState` and `popstate`. You can also manually track page views:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Manual page view tracking
|
||||||
|
ta('pageview');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Tracking Process
|
||||||
|
|
||||||
|
1. **Client-side**: Tracking script loads on visitor's browser
|
||||||
|
2. **Collection**: Script sends tracking data to `/api/collect`
|
||||||
|
3. **Validation**: `CollectController` validates tracking ID and domain
|
||||||
|
4. **Queueing**: Tracking payload is queued as `ProcessTrackingPayload` job
|
||||||
|
5. **Processing**: Queue worker picks up job and:
|
||||||
|
- Creates/updates **Visitor** record (with geolocation)
|
||||||
|
- Creates/retrieves **AnalyticsSession** (30-min timeout)
|
||||||
|
- Records **PageView** or **Event**
|
||||||
|
- Evaluates active **Goals** and records conversions
|
||||||
|
- Updates session metrics (duration, bounce status)
|
||||||
|
6. **Aggregation**: Daily at 02:00 AM, raw data is rolled up into aggregates
|
||||||
|
7. **Reporting**: Dashboard queries combine aggregated + real-time data
|
||||||
|
|
||||||
|
### Session Logic
|
||||||
|
|
||||||
|
- **Session Start**: First page view from a visitor
|
||||||
|
- **Session Timeout**: 30 minutes of inactivity
|
||||||
|
- **Session Duration**: Time between first and last page view
|
||||||
|
- **Bounce**: Session with only 1 page view
|
||||||
|
- **Exit Page**: Last page viewed in session
|
||||||
|
|
||||||
|
### Goal Evaluation
|
||||||
|
|
||||||
|
Goals are evaluated in real-time during tracking:
|
||||||
|
|
||||||
|
1. **URL Destination**: Matches current page URL
|
||||||
|
- Exact: URL must match exactly
|
||||||
|
- Starts with: URL must start with pattern
|
||||||
|
- Contains: URL must contain pattern
|
||||||
|
- Regex: URL must match regex pattern
|
||||||
|
|
||||||
|
2. **Event Match**: Matches event name (and optionally category)
|
||||||
|
|
||||||
|
3. **Duration**: Session duration exceeds threshold
|
||||||
|
|
||||||
|
4. **Pages Per Session**: Page count exceeds threshold
|
||||||
|
|
||||||
|
### Data Aggregation
|
||||||
|
|
||||||
|
Raw tracking data is aggregated daily to improve query performance:
|
||||||
|
|
||||||
|
**Daily Aggregates** (per website, per day):
|
||||||
|
- Total visitors (unique)
|
||||||
|
- Total sessions
|
||||||
|
- Total page views
|
||||||
|
- Bounce count
|
||||||
|
- Total session duration
|
||||||
|
|
||||||
|
**Page Daily Aggregates** (per website, per page, per day):
|
||||||
|
- Page views
|
||||||
|
- Unique page views
|
||||||
|
- Total time on page
|
||||||
|
- Bounce count
|
||||||
|
|
||||||
|
**Query Strategy**:
|
||||||
|
- Historical dates: Query aggregated tables
|
||||||
|
- Today: Query raw tables
|
||||||
|
- Results are merged for complete picture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Structure
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
| Table | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `users` | User accounts |
|
||||||
|
| `websites` | Tracked websites with settings |
|
||||||
|
| `visitors` | Unique visitors with device/location info |
|
||||||
|
| `analytics_sessions` | User sessions with UTM data |
|
||||||
|
| `page_views` | Individual page visits |
|
||||||
|
| `events` | Custom events with metadata |
|
||||||
|
| `goals` | Conversion goal definitions |
|
||||||
|
| `goal_conversions` | Goal completion records |
|
||||||
|
| `funnels` | Funnel definitions |
|
||||||
|
| `segments` | Custom visitor segments |
|
||||||
|
| `alerts` | Alert rule definitions |
|
||||||
|
| `alert_notifications` | Triggered alert records |
|
||||||
|
| `annotations` | Date annotations |
|
||||||
|
| `dashboard_widgets` | Custom dashboard layouts |
|
||||||
|
| `daily_aggregates` | Pre-computed daily metrics |
|
||||||
|
| `page_daily_aggregates` | Per-page daily metrics |
|
||||||
|
|
||||||
|
### Data Retention
|
||||||
|
|
||||||
|
- **Raw Data**: Retained for 365 days (configurable per website)
|
||||||
|
- **Aggregated Data**: Retained indefinitely
|
||||||
|
- **Purge Schedule**: Daily at 03:00 AM via `analytics:purge`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artisan Commands
|
||||||
|
|
||||||
|
### Analytics Commands
|
||||||
|
|
||||||
|
#### 1. Aggregate Data
|
||||||
|
```bash
|
||||||
|
php artisan analytics:aggregate [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--date=YYYY-MM-DD Aggregate specific date (default: yesterday)
|
||||||
|
--website=ID Aggregate specific website (default: all)
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
php artisan analytics:aggregate
|
||||||
|
php artisan analytics:aggregate --date=2026-03-20
|
||||||
|
php artisan analytics:aggregate --website=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Cannot aggregate today's data (prevents incomplete aggregations)
|
||||||
|
|
||||||
|
#### 2. Purge Old Data
|
||||||
|
```bash
|
||||||
|
php artisan analytics:purge [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--website=ID Purge specific website (default: all)
|
||||||
|
--force Skip confirmation prompt
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
php artisan analytics:purge --force
|
||||||
|
php artisan analytics:purge --website=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes raw data older than retention period (default: 365 days).
|
||||||
|
|
||||||
|
#### 3. Check Alerts
|
||||||
|
```bash
|
||||||
|
php artisan analytics:check-alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
Evaluates all active alerts and triggers notifications.
|
||||||
|
|
||||||
|
### Queue Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Process queue jobs
|
||||||
|
php artisan queue:work database --tries=3
|
||||||
|
|
||||||
|
# List failed jobs
|
||||||
|
php artisan queue:failed
|
||||||
|
|
||||||
|
# Retry failed job
|
||||||
|
php artisan queue:retry {job-id}
|
||||||
|
|
||||||
|
# Retry all failed jobs
|
||||||
|
php artisan queue:retry all
|
||||||
|
|
||||||
|
# Flush failed jobs
|
||||||
|
php artisan queue:flush
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear application cache
|
||||||
|
php artisan cache:clear
|
||||||
|
|
||||||
|
# Clear config cache
|
||||||
|
php artisan config:clear
|
||||||
|
|
||||||
|
# Clear route cache
|
||||||
|
php artisan route:clear
|
||||||
|
|
||||||
|
# Clear view cache
|
||||||
|
php artisan view:clear
|
||||||
|
|
||||||
|
# Cache config for production
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Generate API token in dashboard: **Profile** → **API Tokens** → **Create Token**
|
||||||
|
|
||||||
|
Include in requests:
|
||||||
|
```bash
|
||||||
|
Authorization: Bearer YOUR_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
#### GET /api/v1/websites
|
||||||
|
List all websites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
https://analytics.yourdomain.com/api/v1/websites
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/v1/websites/{id}/stats
|
||||||
|
Get website statistics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"https://analytics.yourdomain.com/api/v1/websites/1/stats?from=2026-03-01&to=2026-03-22"
|
||||||
|
```
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- `from` (required): Start date (YYYY-MM-DD)
|
||||||
|
- `to` (required): End date (YYYY-MM-DD)
|
||||||
|
|
||||||
|
#### GET /api/v1/websites/{id}/pages
|
||||||
|
Get page analytics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"https://analytics.yourdomain.com/api/v1/websites/1/pages?from=2026-03-01&to=2026-03-22&limit=20"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/v1/websites/{id}/referrers
|
||||||
|
Get referrer data
|
||||||
|
|
||||||
|
#### GET /api/v1/websites/{id}/events
|
||||||
|
Get event data
|
||||||
|
|
||||||
|
#### GET /api/v1/websites/{id}/visitors
|
||||||
|
Get visitor data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Daily Tasks (Automated)
|
||||||
|
|
||||||
|
These run automatically via cron (see [Task Scheduler](#2-task-scheduler-critical)):
|
||||||
|
|
||||||
|
- **02:00 AM**: Data aggregation
|
||||||
|
- **03:00 AM**: Old data purging
|
||||||
|
- **Every hour**: Alert evaluation
|
||||||
|
|
||||||
|
### Weekly Tasks (Manual)
|
||||||
|
|
||||||
|
#### Check Queue Status
|
||||||
|
```bash
|
||||||
|
# Check if queue worker is running
|
||||||
|
ps aux | grep "queue:work"
|
||||||
|
|
||||||
|
# Check failed jobs
|
||||||
|
php artisan queue:failed
|
||||||
|
|
||||||
|
# Retry failed jobs
|
||||||
|
php artisan queue:retry all
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Monitor Disk Space
|
||||||
|
```bash
|
||||||
|
# Check database size
|
||||||
|
du -h database/database.sqlite
|
||||||
|
|
||||||
|
# Check storage usage
|
||||||
|
du -sh storage/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Review Logs
|
||||||
|
```bash
|
||||||
|
# Check application logs
|
||||||
|
tail -f storage/logs/laravel.log
|
||||||
|
|
||||||
|
# Check queue worker logs (if using supervisor)
|
||||||
|
tail -f storage/logs/queue-worker.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monthly Tasks
|
||||||
|
|
||||||
|
#### Database Optimization
|
||||||
|
|
||||||
|
**SQLite**:
|
||||||
|
```bash
|
||||||
|
sqlite3 database/database.sqlite "VACUUM;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**MySQL**:
|
||||||
|
```sql
|
||||||
|
OPTIMIZE TABLE visitors, analytics_sessions, page_views, events, daily_aggregates, page_daily_aggregates;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Review Data Retention
|
||||||
|
|
||||||
|
Check if retention settings are appropriate:
|
||||||
|
1. Navigate to **Website Settings**
|
||||||
|
2. Review **Data Retention** setting (default: 365 days)
|
||||||
|
3. Adjust based on compliance requirements
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
|
||||||
|
#### Database Backup
|
||||||
|
|
||||||
|
**SQLite**:
|
||||||
|
```bash
|
||||||
|
# Copy database file
|
||||||
|
cp database/database.sqlite backups/database-$(date +%Y%m%d).sqlite
|
||||||
|
|
||||||
|
# Compress
|
||||||
|
gzip backups/database-$(date +%Y%m%d).sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
**MySQL**:
|
||||||
|
```bash
|
||||||
|
mysqldump -u user -p analytics > backups/analytics-$(date +%Y%m%d).sql
|
||||||
|
gzip backups/analytics-$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Application Backup
|
||||||
|
```bash
|
||||||
|
# Backup entire application
|
||||||
|
tar -czf analytics-backup-$(date +%Y%m%d).tar.gz \
|
||||||
|
--exclude=node_modules \
|
||||||
|
--exclude=vendor \
|
||||||
|
--exclude=storage/logs \
|
||||||
|
/path/to/analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
#### Tracking Not Working
|
||||||
|
|
||||||
|
1. **Check queue worker is running**:
|
||||||
|
```bash
|
||||||
|
ps aux | grep "queue:work"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check failed jobs**:
|
||||||
|
```bash
|
||||||
|
php artisan queue:failed
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check tracking domain matches**:
|
||||||
|
- Ensure website domain in dashboard matches actual domain
|
||||||
|
- Domain validation is case-insensitive but must match
|
||||||
|
|
||||||
|
4. **Check browser console**:
|
||||||
|
- Look for CORS errors
|
||||||
|
- Verify tracking script loads
|
||||||
|
|
||||||
|
5. **Check logs**:
|
||||||
|
```bash
|
||||||
|
tail -f storage/logs/laravel.log
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dashboard Shows No Data
|
||||||
|
|
||||||
|
1. **Check if aggregation ran**:
|
||||||
|
```bash
|
||||||
|
php artisan analytics:aggregate --date=yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check date range** - today's data may not appear if only querying aggregates
|
||||||
|
|
||||||
|
3. **Verify tracking is working** - check raw tables:
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM page_views WHERE created_at > datetime('now', '-1 hour');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Alerts Not Triggering
|
||||||
|
|
||||||
|
1. **Check cron is running**:
|
||||||
|
```bash
|
||||||
|
grep CRON /var/log/syslog
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Manually run alert check**:
|
||||||
|
```bash
|
||||||
|
php artisan analytics:check-alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check mail configuration**:
|
||||||
|
```bash
|
||||||
|
php artisan tinker
|
||||||
|
>>> Mail::raw('Test', function($msg) { $msg->to('test@example.com')->subject('Test'); });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High Disk Usage
|
||||||
|
|
||||||
|
1. **Run aggregation** (if not running):
|
||||||
|
```bash
|
||||||
|
php artisan analytics:aggregate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Purge old data**:
|
||||||
|
```bash
|
||||||
|
php artisan analytics:purge --force
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Adjust retention settings** in website configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### 1. Use Redis for Queue & Cache
|
||||||
|
|
||||||
|
```env
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
CACHE_STORE=redis
|
||||||
|
SESSION_DRIVER=redis
|
||||||
|
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database Indexing
|
||||||
|
|
||||||
|
Ensure migrations have run (indexes are included):
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Cache Configuration
|
||||||
|
|
||||||
|
In production:
|
||||||
|
```bash
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Enable OPcache
|
||||||
|
|
||||||
|
Add to `php.ini`:
|
||||||
|
```ini
|
||||||
|
opcache.enable=1
|
||||||
|
opcache.memory_consumption=256
|
||||||
|
opcache.interned_strings_buffer=16
|
||||||
|
opcache.max_accelerated_files=10000
|
||||||
|
opcache.revalidate_freq=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Queue Workers
|
||||||
|
|
||||||
|
Run multiple queue workers for high traffic:
|
||||||
|
```bash
|
||||||
|
# Run 3 workers
|
||||||
|
php artisan queue:work database --tries=3 &
|
||||||
|
php artisan queue:work database --tries=3 &
|
||||||
|
php artisan queue:work database --tries=3 &
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use supervisor with `numprocs=3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### 1. Environment File
|
||||||
|
- Never commit `.env` to version control
|
||||||
|
- Use strong `APP_KEY` (generated automatically)
|
||||||
|
- Set `APP_DEBUG=false` in production
|
||||||
|
|
||||||
|
### 2. Database Credentials
|
||||||
|
- Use strong passwords
|
||||||
|
- Restrict database user permissions
|
||||||
|
- Use separate database user for application
|
||||||
|
|
||||||
|
### 3. HTTPS
|
||||||
|
- Always use HTTPS in production
|
||||||
|
- Update `APP_URL` to use `https://`
|
||||||
|
- Tracking script requires HTTPS for secure cookies
|
||||||
|
|
||||||
|
### 4. CORS
|
||||||
|
- CORS is enabled for `/api/collect` and `/api/t/{id}.js`
|
||||||
|
- Only these endpoints are publicly accessible
|
||||||
|
- Dashboard requires authentication
|
||||||
|
|
||||||
|
### 5. Rate Limiting
|
||||||
|
- Tracking endpoints have rate limiting
|
||||||
|
- API endpoints have Sanctum rate limiting
|
||||||
|
- Adjust in `app/Http/Middleware` if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Resources
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Laravel 11: https://laravel.com/docs/11.x
|
||||||
|
- Alpine.js: https://alpinejs.dev
|
||||||
|
- Tailwind CSS: https://tailwindcss.com
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
- Application: `storage/logs/laravel.log`
|
||||||
|
- Queue Worker: Check supervisor/systemd logs
|
||||||
|
|
||||||
|
### Common Paths
|
||||||
|
- **Application**: `/path/to/analytics`
|
||||||
|
- **Public**: `/path/to/analytics/public`
|
||||||
|
- **Storage**: `/path/to/analytics/storage`
|
||||||
|
- **Database**: `/path/to/analytics/database/database.sqlite`
|
||||||
|
- **Logs**: `/path/to/analytics/storage/logs/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
composer run dev # Start dev environment
|
||||||
|
|
||||||
|
# Queue
|
||||||
|
php artisan queue:work database # Start queue worker
|
||||||
|
php artisan queue:failed # List failed jobs
|
||||||
|
php artisan queue:retry all # Retry failed jobs
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
php artisan analytics:aggregate # Aggregate yesterday's data
|
||||||
|
php artisan analytics:purge --force # Purge old data
|
||||||
|
php artisan analytics:check-alerts # Check alerts
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
php artisan cache:clear # Clear cache
|
||||||
|
php artisan config:cache # Cache config (production)
|
||||||
|
|
||||||
|
# Maintenance
|
||||||
|
php artisan down # Enable maintenance mode
|
||||||
|
php artisan up # Disable maintenance mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Entry
|
||||||
|
|
||||||
|
```cron
|
||||||
|
* * * * * cd /path/to/analytics && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supervisor Config
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:analytics-queue]
|
||||||
|
command=php /path/to/analytics/artisan queue:work database --tries=3
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
user=www-data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-03-22
|
||||||
|
**Laravel Version**: 11.50.0
|
||||||
|
**PHP Version**: 8.2+ (Compatible with 8.3)
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
# Analytics Platform — Marketing Plan
|
||||||
|
|
||||||
|
> Version 1.0 — 2026-05-25
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Overview
|
||||||
|
|
||||||
|
A self-hosted, privacy-focused web analytics platform. Users install a lightweight JavaScript snippet and get real-time dashboards, goal tracking, funnels, segments, alerts, and a full API — without sending visitor data to Google, Meta, or any third party.
|
||||||
|
|
||||||
|
**Core value proposition:** The power of Google Analytics, with the privacy of Plausible, at a price that scales with your hosting costs rather than your traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Problem We Solve
|
||||||
|
|
||||||
|
- **Privacy regulations** (GDPR, CCPA, ePrivacy) make GA4 legally risky in many markets — especially for EU-based businesses
|
||||||
|
- **GA4 is genuinely hard to use** — it's built for enterprise advertisers, not product teams or indie developers
|
||||||
|
- **Third-party SaaS analytics is expensive at scale** — Plausible, Fathom, and Mixpanel charge $9–$999/month based on pageviews or events
|
||||||
|
- **Self-hosted alternatives are limited** — Umami and Matomo exist but lack goals, funnels, segments, and API access in their free tiers
|
||||||
|
|
||||||
|
Our platform gives technical users a full-featured analytics stack they own and control, deployed on their own infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Target Audience
|
||||||
|
|
||||||
|
### Primary — Indie Developers & Solo SaaS Founders
|
||||||
|
- Building products and tired of paying per-pageview fees
|
||||||
|
- Care about user privacy and GDPR compliance
|
||||||
|
- Comfortable self-hosting on a $5–20/month VPS
|
||||||
|
- Want real metrics: funnels, goals, conversion rates — not just pageviews
|
||||||
|
- Often run multiple projects and want one analytics instance for all of them
|
||||||
|
|
||||||
|
### Secondary — Small Agencies (2–20 person)
|
||||||
|
- Managing analytics for 5–50 client websites
|
||||||
|
- Frustrated by the cost of per-site SaaS tools at scale
|
||||||
|
- Want white-label or multi-tenant capability
|
||||||
|
- Need API access to build client-facing dashboards or reports
|
||||||
|
|
||||||
|
### Tertiary — Privacy-Conscious Businesses
|
||||||
|
- Companies under strict data residency requirements (EU, healthcare, finance)
|
||||||
|
- Marketing teams that have been told to remove GA4 by legal
|
||||||
|
- Businesses that have made privacy a public-facing value
|
||||||
|
|
||||||
|
### Who This Is NOT For (yet)
|
||||||
|
- Large enterprises with dedicated analytics teams (need Snowflake, dbt, Looker)
|
||||||
|
- Non-technical users who can't manage a server
|
||||||
|
- Teams that need mobile app SDKs (web only for now)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Competitive Landscape
|
||||||
|
|
||||||
|
| Product | Model | Price | Self-hosted | Goals/Funnels | API |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Google Analytics 4** | Free (you are the product) | Free | ❌ | ✅ | ✅ |
|
||||||
|
| **Plausible** | SaaS | $9–$19+/mo | ✅ (paid) | Limited | ✅ |
|
||||||
|
| **Fathom** | SaaS | $14+/mo | ❌ | Limited | ✅ |
|
||||||
|
| **Matomo** | Open source / SaaS | Free self-hosted | ✅ | ✅ | ✅ |
|
||||||
|
| **Umami** | Open source | Free self-hosted | ✅ | Limited | ✅ |
|
||||||
|
| **Mixpanel** | SaaS | $0–$999+/mo | ❌ | ✅ | ✅ |
|
||||||
|
| **Our Platform** | Open source | Free self-hosted | ✅ | ✅ Full | ✅ Full |
|
||||||
|
|
||||||
|
### Our Differentiators
|
||||||
|
1. **Full feature set, free** — goals, funnels, segments, alerts, custom dashboards, annotations, API — all included, no paid tiers
|
||||||
|
2. **One instance, unlimited websites** — no per-site pricing
|
||||||
|
3. **Developer-first API** — pull any metric programmatically for dashboards or integrations
|
||||||
|
4. **Laravel ecosystem** — easy for PHP/Laravel developers to customize, extend, and contribute
|
||||||
|
5. **Truly self-hosted** — your data never leaves your server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Positioning Statement
|
||||||
|
|
||||||
|
**For** indie developers, SaaS founders, and small agencies **who** are frustrated by the cost, complexity, or privacy implications of existing analytics tools, **our platform is** a self-hosted web analytics solution **that** gives them full-featured, GDPR-compliant analytics — goals, funnels, segments, and API access — deployed on their own infrastructure in under 10 minutes. **Unlike** Plausible or Fathom, **we** don't charge per pageview and don't hold your data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pricing Strategy
|
||||||
|
|
||||||
|
The platform is **free and open source**. Revenue comes from value-added services:
|
||||||
|
|
||||||
|
### Tier 1 — Self-Hosted (Free, Open Source)
|
||||||
|
- Full platform, unlimited websites, unlimited data
|
||||||
|
- Community support (GitHub issues, Discord)
|
||||||
|
- You manage hosting
|
||||||
|
|
||||||
|
### Tier 2 — Managed Hosting (Future — $9/mo)
|
||||||
|
- We host it for you on our infrastructure
|
||||||
|
- Automatic updates, backups, uptime monitoring
|
||||||
|
- Email support
|
||||||
|
- Positioned against Plausible/Fathom on price
|
||||||
|
|
||||||
|
### Tier 3 — Business Hosting (Future — $29/mo)
|
||||||
|
- Managed hosting + priority support
|
||||||
|
- Custom domain for tracker script (CDN included)
|
||||||
|
- SLA, audit logs
|
||||||
|
- Targeted at agencies managing client analytics
|
||||||
|
|
||||||
|
### Why Open Source First
|
||||||
|
- Builds trust through transparency (critical for a privacy tool)
|
||||||
|
- Community contributions improve the product faster
|
||||||
|
- Developers try it free → recommend it → agencies pay for managed hosting
|
||||||
|
- OSS projects in this space (Umami, Matomo) have strong long-term growth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Go-to-Market Strategy
|
||||||
|
|
||||||
|
### Phase 1 — Launch (Months 1–2)
|
||||||
|
|
||||||
|
**Goal:** Get 500 GitHub stars and 100 self-hosted installs.
|
||||||
|
|
||||||
|
**Channels:**
|
||||||
|
|
||||||
|
- **Product Hunt launch** — Submit on a Tuesday morning (US time). Write a detailed maker story about why you built it. Reply to every comment within 24 hours.
|
||||||
|
- **Hacker News Show HN** — Write a technical post explaining the architecture decisions (why Laravel, why self-hosted, how the aggregation pipeline works). Devs respect specifics.
|
||||||
|
- **Reddit** — Post in r/selfhosted, r/privacy, r/webdev, r/Laravel, r/Entrepreneur. Be genuinely helpful, not promotional. Show the product solving a real problem.
|
||||||
|
- **Dev.to / Hashnode article** — "I built a Google Analytics alternative in Laravel — here's how the data pipeline works." Technical depth attracts developers who will self-host and share.
|
||||||
|
- **Twitter/X** — Share the launch, post screenshots of the dashboard, engage with #buildinpublic and #Laravel communities.
|
||||||
|
|
||||||
|
**Launch content to prepare:**
|
||||||
|
- A 90-second screen-recorded demo video (no voiceover, just the UI with captions)
|
||||||
|
- A live demo instance at `demo.youranalytics.app` pre-seeded with realistic data
|
||||||
|
- A 1-click deploy button for Railway, Render, or DigitalOcean App Platform
|
||||||
|
- A `docker-compose.yml` so developers can run it in one command
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Growth (Months 3–6)
|
||||||
|
|
||||||
|
**Goal:** 2,000 GitHub stars, 500 active installs, 50 managed hosting customers.
|
||||||
|
|
||||||
|
**SEO — Long-tail content strategy:**
|
||||||
|
|
||||||
|
Write in-depth comparison articles and tutorials that rank for searches developers actually make:
|
||||||
|
|
||||||
|
| Article Title | Target Keyword | Intent |
|
||||||
|
|---|---|---|
|
||||||
|
| "Google Analytics vs Self-Hosted: Why I Switched" | `self-hosted analytics` | Awareness |
|
||||||
|
| "Plausible Alternative: Full-Featured and Free" | `plausible alternative` | Comparison |
|
||||||
|
| "GDPR-Compliant Analytics Without Cookies" | `gdpr analytics no cookies` | Pain point |
|
||||||
|
| "How to Self-Host Web Analytics on a $5 VPS" | `self host analytics` | How-to |
|
||||||
|
| "Laravel Web Analytics — Building a Tracker from Scratch" | `laravel analytics` | Developer |
|
||||||
|
| "Matomo vs [Our Platform]: Which Should You Use?" | `matomo alternative` | Comparison |
|
||||||
|
| "How I Track Conversions Without Google" | `track conversions without google analytics` | Pain point |
|
||||||
|
|
||||||
|
**Community building:**
|
||||||
|
- Open a Discord server — #announcements, #support, #showcase, #dev
|
||||||
|
- Monthly "what are you tracking?" thread on Discord/GitHub Discussions
|
||||||
|
- Publish a public roadmap (GitHub Projects or Linear)
|
||||||
|
- Recognize contributors on a CONTRIBUTORS page and in release notes
|
||||||
|
|
||||||
|
**Developer relations:**
|
||||||
|
- Publish an integration guide for major frameworks (Next.js, Nuxt, SvelteKit, WordPress)
|
||||||
|
- Write an "extending the platform" post — how to add a custom report or data source
|
||||||
|
- Offer a "built with" badge for developers using the platform on their sites
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Monetization (Months 6–12)
|
||||||
|
|
||||||
|
**Goal:** Launch managed hosting, reach $2,000 MRR.
|
||||||
|
|
||||||
|
**Managed hosting launch sequence:**
|
||||||
|
1. Announce to existing community first — offer 30-day free trial, no credit card
|
||||||
|
2. Reach out personally to power users who have filed GitHub issues or starred the repo
|
||||||
|
3. Product Hunt launch #2: "Managed hosting for [Platform] — your analytics, zero maintenance"
|
||||||
|
4. Write a comparison post: "Self-hosting vs managed: what's the real cost of your time?"
|
||||||
|
|
||||||
|
**Partnership opportunities:**
|
||||||
|
- Approach Laravel Forge, Ploi, or Coolify about a one-click deploy integration
|
||||||
|
- Reach out to Laravel News for a sponsored post or feature
|
||||||
|
- Partner with privacy-focused hosting providers (Hetzner, Contabo, Greenhost) for joint content
|
||||||
|
- Offer an affiliate program for influencers in the self-hosting / indie hacker space
|
||||||
|
|
||||||
|
**Agency channel:**
|
||||||
|
- Create an "Agency Partner" page — explain multi-client dashboard capabilities
|
||||||
|
- Reach out directly to 50 small agencies who post about GDPR/analytics on Twitter/LinkedIn
|
||||||
|
- Offer bulk pricing and white-labeling for agencies managing 20+ sites
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Content Calendar (First 90 Days)
|
||||||
|
|
||||||
|
| Week | Content | Channel |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Launch post — "I built a GDPR-compliant analytics platform" | HN, Reddit, Dev.to |
|
||||||
|
| 1 | Product Hunt submission | Product Hunt |
|
||||||
|
| 2 | Demo video (90 seconds, no voiceover) | Twitter, LinkedIn |
|
||||||
|
| 3 | Tutorial: "Deploy in 10 minutes on a $5 VPS" | Blog, Dev.to, YouTube |
|
||||||
|
| 4 | "How I built the data pipeline" (technical deep-dive) | HN, Dev.to |
|
||||||
|
| 5 | Comparison: "vs Plausible" | Blog, SEO |
|
||||||
|
| 6 | Integration guide: Next.js | Blog, r/nextjs |
|
||||||
|
| 7 | Comparison: "vs Matomo" | Blog, SEO |
|
||||||
|
| 8 | "3 months of building in public" update | Twitter, HN |
|
||||||
|
| 9 | Integration guide: WordPress | Blog, r/Wordpress |
|
||||||
|
| 10 | Webinar: "Self-hosted analytics for GDPR compliance" | LinkedIn Live |
|
||||||
|
| 11 | Community showcase — what users are tracking | Blog, Discord |
|
||||||
|
| 12 | Roadmap reveal — what's coming in the next quarter | GitHub, Discord |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Distribution Channels — Priority Order
|
||||||
|
|
||||||
|
| Channel | Effort | Expected Reach | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GitHub (quality README + OSS) | Low | Very high (developer discovery) | 🔴 Critical |
|
||||||
|
| Hacker News Show HN | Low | High | 🔴 Critical |
|
||||||
|
| Product Hunt | Medium | High | 🔴 Critical |
|
||||||
|
| Reddit (r/selfhosted, r/webdev) | Low | Medium | 🟡 High |
|
||||||
|
| SEO / blog content | High | Very high (long-term) | 🟡 High |
|
||||||
|
| Twitter/X #buildinpublic | Low | Medium | 🟡 High |
|
||||||
|
| Dev.to / Hashnode | Medium | Medium | 🟡 High |
|
||||||
|
| YouTube tutorials | High | High (long-term) | 🟢 Medium |
|
||||||
|
| LinkedIn | Medium | Medium (agencies) | 🟢 Medium |
|
||||||
|
| Newsletter sponsorships (TLDR, Bytes) | High (cost) | High | 🟢 Medium |
|
||||||
|
| Laravel News | Low | High (niche) | 🟢 Medium |
|
||||||
|
| Podcast appearances | Medium | Medium | ⚪ Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Key Metrics
|
||||||
|
|
||||||
|
### Awareness
|
||||||
|
- GitHub stars (target: 500 at end of month 2, 2,000 at end of month 6)
|
||||||
|
- Website unique visitors/month
|
||||||
|
- Organic search impressions
|
||||||
|
|
||||||
|
### Acquisition
|
||||||
|
- GitHub clones/downloads
|
||||||
|
- Docker Hub pulls
|
||||||
|
- New self-hosted installs (estimated from tracking snippet registrations)
|
||||||
|
- Managed hosting signups
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
- % of signups who add a website within 24 hours
|
||||||
|
- % who receive their first tracking event within 48 hours
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
- Weekly active installs (websites with at least one event in 7 days)
|
||||||
|
- Managed hosting monthly churn rate (target: <3%)
|
||||||
|
|
||||||
|
### Revenue (Phase 3+)
|
||||||
|
- MRR — managed hosting subscriptions
|
||||||
|
- ARPU — average revenue per managed hosting customer
|
||||||
|
- LTV:CAC ratio (target: >3:1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Messaging — Copy Bank
|
||||||
|
|
||||||
|
### Taglines
|
||||||
|
- "Your analytics. Your server. Your data."
|
||||||
|
- "The analytics platform Google doesn't want you to build."
|
||||||
|
- "Full-featured. Self-hosted. Free forever."
|
||||||
|
- "Stop paying per pageview. Own your data."
|
||||||
|
- "Privacy-first analytics for people who care about both words."
|
||||||
|
|
||||||
|
### One-liner for directories and bios
|
||||||
|
> A self-hosted, open-source web analytics platform with real-time dashboards, goals, funnels, segments, and a full API — GDPR-compliant by design.
|
||||||
|
|
||||||
|
### Pain-point headlines (for landing page A/B tests)
|
||||||
|
- "GA4 is confusing. Cookie banners are annoying. There's a better way."
|
||||||
|
- "Why are you still paying $19/month per website for basic analytics?"
|
||||||
|
- "Your visitors' data shouldn't live on Google's servers."
|
||||||
|
- "Analytics that respects your users — and your budget."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Launch Checklist
|
||||||
|
|
||||||
|
- [ ] Write a comprehensive GitHub README with screenshots, quick-start, and feature list
|
||||||
|
- [ ] Create a live demo instance at `demo.youranalytics.app` with realistic seed data
|
||||||
|
- [ ] Record a 90-second demo video (dashboard walkthrough, no talking needed)
|
||||||
|
- [ ] Write `docker-compose.yml` for one-command local setup
|
||||||
|
- [ ] Submit a one-click deploy to Railway or Render
|
||||||
|
- [ ] Set up a landing page (separate from the app) with pricing and docs
|
||||||
|
- [ ] Open GitHub Discussions for community support
|
||||||
|
- [ ] Create a Discord server
|
||||||
|
- [ ] Write the Product Hunt submission (tagline, description, first comment)
|
||||||
|
- [ ] Write the Hacker News Show HN post
|
||||||
|
- [ ] Prepare 5 Reddit posts tailored to each subreddit's culture
|
||||||
|
- [ ] Write a launch blog post on Dev.to
|
||||||
|
- [ ] Build a list of 50 relevant Twitter/LinkedIn accounts to engage with at launch
|
||||||
|
- [ ] Set up Google Search Console and submit sitemap
|
||||||
|
- [ ] Schedule the Product Hunt launch for a Tuesday at 00:01 PST
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Risk & Mitigation
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Crowded market (Umami, Matomo) | High | Medium | Differentiate on full feature set + developer experience |
|
||||||
|
| "Just use Plausible" objections | High | Low | Free self-hosting undercuts their price argument entirely |
|
||||||
|
| Self-hosting barrier too high | Medium | High | One-click deploy, `docker-compose`, managed hosting option |
|
||||||
|
| Feature parity with GA4 expected | Medium | Medium | Clearly scope to product analytics, not advertising attribution |
|
||||||
|
| Laravel not seen as "serious" for analytics | Low | Medium | Technical blog content showing architecture depth |
|
||||||
|
| Maintenance burden for OSS | Medium | High | Clear contribution guide, automated testing, small focused scope |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This plan is a living document. Revisit and update after each major milestone.*
|
||||||
@@ -1,59 +1,492 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
<img src="public/assets/img/auth_logo_icon_main.png" alt="LibreMetrics" width="80">
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## About Laravel
|
<h1 align="center">LibreMetrics</h1>
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
<p align="center">
|
||||||
|
<strong>Open-source, self-hosted web analytics. Your data. Your server. No Google.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
<p align="center">
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
<a href="https://github.com/yourusername/libremetrics/actions"><img src="https://img.shields.io/github/actions/workflow/status/yourusername/libremetrics/tests.yml?label=tests&style=flat-square" alt="Tests"></a>
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
<a href="https://github.com/yourusername/libremetrics/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License"></a>
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
<img src="https://img.shields.io/badge/PHP-8.2+-purple?style=flat-square" alt="PHP 8.2+">
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
<img src="https://img.shields.io/badge/Laravel-11-red?style=flat-square" alt="Laravel 11">
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
<img src="https://img.shields.io/badge/tests-80%20passing-brightgreen?style=flat-square" alt="80 tests passing">
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
</p>
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
<p align="center">
|
||||||
|
<a href="#-features">Features</a> •
|
||||||
|
<a href="#-quick-start">Quick Start</a> •
|
||||||
|
<a href="#-installation">Installation</a> •
|
||||||
|
<a href="#-tracking-setup">Tracking Setup</a> •
|
||||||
|
<a href="#-api">API</a> •
|
||||||
|
<a href="#-contributing">Contributing</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Learning Laravel
|
---
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
## What is LibreMetrics?
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
LibreMetrics is a full-featured, self-hosted web analytics platform built with Laravel 11. It gives you the depth of Google Analytics without handing your visitors' data to a third party — and without the per-pageview pricing of SaaS alternatives like Plausible or Fathom.
|
||||||
|
|
||||||
## Laravel Sponsors
|
Install it on a $5 VPS, point your tracking snippet at it, and get real-time dashboards, goal tracking, funnels, segments, custom alerts, and a REST API — all on infrastructure you own and control.
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
**Why self-host?**
|
||||||
|
- 🔒 **Privacy by design** — visitor data never leaves your server, GDPR-compliant out of the box
|
||||||
|
- 💰 **One price: $0** — unlimited websites, unlimited pageviews, no per-seat fees
|
||||||
|
- 🔧 **Full control** — extend it, customize it, integrate it with anything
|
||||||
|
- 🚫 **No cookie banners needed** — no cross-site tracking, no third-party scripts
|
||||||
|
|
||||||
### Premium Partners
|
---
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
## ✨ Features
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
### Core Analytics
|
||||||
|
- **Real-time dashboard** — see active visitors right now with a live counter
|
||||||
|
- **Key metrics** — page views, unique visitors, sessions, bounce rate, avg. session duration, pages/session
|
||||||
|
- **Comparison mode** — compare any period vs the previous one, with delta percentages on every card
|
||||||
|
- **Visitors & page views chart** — daily or hourly granularity, with sessions overlay
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
### Audience Reports
|
||||||
|
- 🌍 **Geographic** — visitors by country, region, and city with a world map visualization
|
||||||
|
- 💻 **Technology** — browser, OS, device type, screen resolution breakdown
|
||||||
|
- 🌐 **Language** — browser language distribution
|
||||||
|
- 🔄 **New vs. Returning** — segment first-time vs. repeat visitors
|
||||||
|
|
||||||
## Code of Conduct
|
### Acquisition Reports
|
||||||
|
- **Traffic sources** — direct, organic, referral, and social split
|
||||||
|
- **Campaigns (UTM)** — group sessions by `utm_source`, `utm_medium`, `utm_campaign`
|
||||||
|
- **Referrers** — top referring domains and full URLs
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
### Behavior Reports
|
||||||
|
- **Top pages** — views, unique views, avg. time on page, bounce rate per path
|
||||||
|
- **Landing pages** — entry pages with session count and bounce rate
|
||||||
|
- **Exit pages** — where visitors are leaving
|
||||||
|
|
||||||
## Security Vulnerabilities
|
### Events & Goals
|
||||||
|
- **Custom event tracking** — fire events from your frontend with category, label, and value
|
||||||
|
- **Events report** — grouped by name/category, with drill-down and trending chart
|
||||||
|
- **4 goal types:**
|
||||||
|
- URL destination (exact, starts-with, contains, regex)
|
||||||
|
- Event match (name + optional category)
|
||||||
|
- Session duration (> N seconds)
|
||||||
|
- Pages per session (≥ N pages)
|
||||||
|
- **Conversions report** — completion count, conversion rate, goal value totals, sparklines on dashboard
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
### Advanced Features
|
||||||
|
- **Funnels** — define multi-step sequences and visualize drop-off at each step
|
||||||
|
- **Segments** — filter any report by visitor attributes (device, country, browser, etc.)
|
||||||
|
- **Custom dashboards** — pin widgets to a personal overview page
|
||||||
|
- **Alerts** — get notified by email when traffic spikes, drops, or a goal hits a threshold
|
||||||
|
- **Annotations** — add notes to specific dates that appear on charts
|
||||||
|
- **Data export** — export any report to CSV
|
||||||
|
- **UTM builder** — generate campaign URLs with UTM parameters right in the app
|
||||||
|
|
||||||
## License
|
### Developer Features
|
||||||
|
- **REST API** — pull any metric programmatically with token authentication
|
||||||
|
- **API token management** — create and revoke tokens from the dashboard
|
||||||
|
- **Async ingestion pipeline** — collection endpoint returns instantly; processing is queued
|
||||||
|
- **Bot filtering** — known crawlers are silently rejected
|
||||||
|
- **1×1 tracking pixel** — no-JavaScript fallback for email or restricted environments
|
||||||
|
- **Ad-blocker resistant endpoints** — alternate `/cdn/` paths for the tracker script and collection
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
### Performance & Scale
|
||||||
|
- **Daily aggregation** — raw data is pre-aggregated nightly for fast dashboard queries
|
||||||
|
- **Hybrid query strategy** — aggregates for history, raw tables for today's live data
|
||||||
|
- **Configurable data retention** — set per-website retention from 30 days to forever
|
||||||
|
- **Purge command** — automatically clean up raw data beyond the retention window
|
||||||
|
- **Performance indexes** — all date-range + website_id query paths are indexed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
The fastest path to a running instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/libremetrics.git
|
||||||
|
cd libremetrics
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
php artisan migrate
|
||||||
|
npm install && npm run build
|
||||||
|
composer run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8000`, register an account, add your first website, and copy the tracking snippet. Done.
|
||||||
|
|
||||||
|
> **`composer run dev`** starts four processes in parallel: web server, queue worker, log viewer, and Vite dev server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
| Requirement | Version |
|
||||||
|
|---|---|
|
||||||
|
| PHP | 8.2+ |
|
||||||
|
| Composer | 2.x |
|
||||||
|
| Node.js | 18+ |
|
||||||
|
| Database | SQLite (dev) / MySQL 8+ / PostgreSQL 14+ |
|
||||||
|
| Queue | Database (dev) / Redis (production) |
|
||||||
|
|
||||||
|
### Step-by-step
|
||||||
|
|
||||||
|
**1. Clone and install dependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/libremetrics.git libremetrics
|
||||||
|
cd libremetrics
|
||||||
|
composer install
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure environment**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` for your environment. Minimum required changes:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_URL=https://analytics.yourdomain.com
|
||||||
|
|
||||||
|
# Database (SQLite works great for small installs)
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
|
||||||
|
# Or for MySQL/PostgreSQL:
|
||||||
|
# DB_CONNECTION=mysql
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_DATABASE=libremetrics
|
||||||
|
# DB_USERNAME=your_user
|
||||||
|
# DB_PASSWORD=your_password
|
||||||
|
|
||||||
|
# Mail (for alerts and email verification)
|
||||||
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=your-smtp-host
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=your@email.com
|
||||||
|
MAIL_PASSWORD=your-password
|
||||||
|
MAIL_FROM_ADDRESS=analytics@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run migrations and build assets**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Start the application**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (all-in-one):
|
||||||
|
composer run dev
|
||||||
|
|
||||||
|
# Production — use a process supervisor for these:
|
||||||
|
php artisan serve # or configure Nginx + PHP-FPM
|
||||||
|
php artisan queue:work database --tries=3 # REQUIRED: processes tracking events
|
||||||
|
php artisan schedule:work # REQUIRED: aggregation, purge, alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ The queue worker and scheduler are **critical**. Without the queue worker, tracking events are dropped. Without the scheduler, reports slow down over time and alerts never fire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Production Setup (Nginx + PHP-FPM)
|
||||||
|
|
||||||
|
Point your Nginx document root to the `public/` directory:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name analytics.yourdomain.com;
|
||||||
|
root /var/www/libremetrics/public;
|
||||||
|
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supervisor config for the queue worker:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:libremetrics-queue]
|
||||||
|
command=php /var/www/libremetrics/artisan queue:work database --tries=3 --timeout=60
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
user=www-data
|
||||||
|
numprocs=2
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/www/libremetrics/storage/logs/queue.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cron entry for the scheduler:**
|
||||||
|
|
||||||
|
```cron
|
||||||
|
* * * * * cd /var/www/libremetrics && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Tracking Setup
|
||||||
|
|
||||||
|
### 1. Add a website
|
||||||
|
|
||||||
|
Register, go to **Websites → Add Website**, enter your site's name and domain. You'll get a tracking ID like `TA-XXXXXXXXXX`.
|
||||||
|
|
||||||
|
### 2. Install the snippet
|
||||||
|
|
||||||
|
Paste this into your HTML before `</head>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script async src="https://analytics.yourdomain.com/api/t/YOUR_TRACKING_ID.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Page views, sessions, and visitor data start flowing immediately.
|
||||||
|
|
||||||
|
### 3. Track custom events
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Track a button click
|
||||||
|
ta('event', 'signup_click', {
|
||||||
|
category: 'engagement',
|
||||||
|
label: 'hero_cta',
|
||||||
|
value: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track a purchase
|
||||||
|
ta('event', 'purchase', {
|
||||||
|
category: 'ecommerce',
|
||||||
|
label: 'checkout',
|
||||||
|
value: 49.99
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Single-page application support
|
||||||
|
|
||||||
|
The tracker automatically detects route changes via `history.pushState` and `popstate`. For manual control:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ta('pageview'); // manually fire a page view
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. No-JS fallback (tracking pixel)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<noscript>
|
||||||
|
<img src="https://analytics.yourdomain.com/api/pixel?tid=YOUR_TRACKING_ID"
|
||||||
|
style="position:absolute;left:-9999px;" alt="">
|
||||||
|
</noscript>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 API
|
||||||
|
|
||||||
|
LibreMetrics includes a versioned REST API for pulling analytics data programmatically.
|
||||||
|
|
||||||
|
**Generate a token:** Dashboard → Profile → API Tokens → Create Token
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
```bash
|
||||||
|
Authorization: Bearer YOUR_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/websites List your websites
|
||||||
|
GET /api/v1/websites/{id}/stats Summary metrics (with date range)
|
||||||
|
GET /api/v1/websites/{id}/pages Top pages
|
||||||
|
GET /api/v1/websites/{id}/referrers Traffic sources
|
||||||
|
GET /api/v1/websites/{id}/events Events report
|
||||||
|
GET /api/v1/websites/{id}/visitors Audience breakdown
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — get stats for the last 30 days:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"https://analytics.yourdomain.com/api/v1/websites/1/stats?from=2026-04-25&to=2026-05-25"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"page_views": 14823,
|
||||||
|
"visitors": 4201,
|
||||||
|
"sessions": 5318,
|
||||||
|
"bounce_rate": 42.3,
|
||||||
|
"avg_duration": 187,
|
||||||
|
"avg_pages": 2.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Artisan Commands
|
||||||
|
|
||||||
|
| Command | Description | Runs |
|
||||||
|
|---|---|---|
|
||||||
|
| `analytics:aggregate` | Roll up raw data into daily aggregates | Daily at 02:00 |
|
||||||
|
| `analytics:purge --force` | Delete raw data beyond retention window | Daily at 03:00 |
|
||||||
|
| `analytics:check-alerts` | Evaluate alert rules, send notifications | Every hour |
|
||||||
|
|
||||||
|
**Run manually:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Aggregate a specific date
|
||||||
|
php artisan analytics:aggregate --date=2026-05-24
|
||||||
|
|
||||||
|
# Aggregate for one website only
|
||||||
|
php artisan analytics:aggregate --website=1
|
||||||
|
|
||||||
|
# Purge old data (prompts for confirmation without --force)
|
||||||
|
php artisan analytics:purge --force
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
80 tests, all passing. The suite covers collection validation, website authorization, analytics query aggregation, goal evaluation, and auth flows.
|
||||||
|
|
||||||
|
```
|
||||||
|
Tests: 80 passed (180 assertions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
- [ ] User flow / Sankey diagram
|
||||||
|
- [ ] Docker Compose one-command setup
|
||||||
|
- [ ] One-click deploy (Railway, Render, DigitalOcean)
|
||||||
|
- [ ] Multi-user teams with role-based access
|
||||||
|
- [ ] Managed hosting option
|
||||||
|
- [ ] Mobile app SDKs (iOS, Android, React Native)
|
||||||
|
- [ ] Webhook notifications for alerts
|
||||||
|
- [ ] Scheduled email reports (weekly digest)
|
||||||
|
|
||||||
|
Have an idea? [Open a discussion](https://github.com/yourusername/libremetrics/discussions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome and appreciated. Here's how to get started:
|
||||||
|
|
||||||
|
**1. Fork and clone**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/libremetrics.git
|
||||||
|
cd libremetrics
|
||||||
|
composer install && npm install
|
||||||
|
cp .env.example .env && php artisan key:generate
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create a branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/your-feature-name
|
||||||
|
# or
|
||||||
|
git checkout -b fix/your-bug-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Make your changes**
|
||||||
|
|
||||||
|
- Write tests for new features
|
||||||
|
- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style (enforced by Laravel Pint)
|
||||||
|
- Keep controllers thin — business logic belongs in services
|
||||||
|
|
||||||
|
**4. Run the test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test
|
||||||
|
vendor/bin/pint --test # check code style
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Open a pull request**
|
||||||
|
|
||||||
|
Describe what you changed and why. Reference any related issues.
|
||||||
|
|
||||||
|
### Areas where help is especially welcome
|
||||||
|
|
||||||
|
- Dashboard UI polish and new chart types
|
||||||
|
- User flow / Sankey diagram (see roadmap)
|
||||||
|
- Docker / deployment tooling
|
||||||
|
- Integration guides for popular frameworks
|
||||||
|
- Translations / i18n
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client website (JS snippet)
|
||||||
|
→ POST /api/e (CollectController — validates, queues, returns instantly)
|
||||||
|
→ ProcessTrackingPayload job (async)
|
||||||
|
→ Creates/updates: Visitor, AnalyticsSession, PageView or Event
|
||||||
|
→ GoalEvaluationService checks active goals → records GoalConversion
|
||||||
|
→ Daily at 02:00: analytics:aggregate
|
||||||
|
→ Rolls raw tables → daily_aggregates + page_daily_aggregates
|
||||||
|
→ Dashboard queries:
|
||||||
|
→ Aggregates for historical dates (fast)
|
||||||
|
→ Raw tables for today (live)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key services:**
|
||||||
|
|
||||||
|
| Service | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `AnalyticsQueryService` | All aggregation and report queries — keeps controllers thin |
|
||||||
|
| `GoalEvaluationService` | Checks every incoming payload against active goals |
|
||||||
|
| `FunnelAnalysisService` | Computes drop-off rates across funnel steps |
|
||||||
|
| `ProcessTrackingPayload` | Queued job: visitor resolution, session management, geolocation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
LibreMetrics is open-source software licensed under the [MIT license](LICENSE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Built With
|
||||||
|
|
||||||
|
- [Laravel 11](https://laravel.com) — PHP framework
|
||||||
|
- [Laravel Fortify](https://github.com/laravel/fortify) — Authentication (with 2FA)
|
||||||
|
- [Laravel Sanctum](https://github.com/laravel/sanctum) — API token authentication
|
||||||
|
- [Alpine.js](https://alpinejs.dev) — Lightweight frontend reactivity
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com) — Utility-first CSS
|
||||||
|
- [Chart.js](https://www.chartjs.org) — Charts and visualizations
|
||||||
|
- [stevebauman/location](https://github.com/stevebauman/location) — IP geolocation
|
||||||
|
- [jaybizzle/crawler-detect](https://github.com/JayBizzle/Crawler-Detect) — Bot filtering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Made with ❤️ — if LibreMetrics saves you money or headaches, consider giving it a ⭐</sub>
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Analytics Platform — Project Status
|
||||||
|
|
||||||
|
> Last updated: 2026-05-25
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is This
|
||||||
|
|
||||||
|
A self-hosted web analytics platform built with Laravel 11 — a privacy-focused alternative to Google Analytics or Plausible. Users add a lightweight JavaScript snippet to their website and get real-time dashboards, AI-ready data pipelines, goal/funnel tracking, and a full REST API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Laravel 11, PHP 8.2+ |
|
||||||
|
| Auth | Laravel Fortify (2FA ready) |
|
||||||
|
| Frontend | Blade + Alpine.js + Tailwind CSS |
|
||||||
|
| Charts | Chart.js |
|
||||||
|
| Database | SQLite (dev) / MySQL or PostgreSQL (prod) |
|
||||||
|
| Queue | Database driver (dev) / Redis + Horizon (prod) |
|
||||||
|
| API | Laravel Sanctum (token auth) |
|
||||||
|
| Geolocation | stevebauman/location |
|
||||||
|
| Bot detection | jaybizzle/crawler-detect |
|
||||||
|
| Build | Vite |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Status
|
||||||
|
|
||||||
|
### ✅ Done — Core Platform
|
||||||
|
|
||||||
|
**Authentication & Users**
|
||||||
|
- Registration, login, password reset, email verification via Fortify
|
||||||
|
- Two-factor authentication support
|
||||||
|
- Profile management
|
||||||
|
- API token management (Sanctum)
|
||||||
|
|
||||||
|
**Website Management**
|
||||||
|
- Full CRUD for tracked websites
|
||||||
|
- Embed snippet page with copy-ready `<script>` tag
|
||||||
|
- WebsitePolicy — all queries scoped to authenticated owner
|
||||||
|
|
||||||
|
**Data Collection Pipeline**
|
||||||
|
- `POST /api/e` and `POST /api/collect` endpoints
|
||||||
|
- Async ingestion via `ProcessTrackingPayload` queued job
|
||||||
|
- Bot filtering (CrawlerDetect)
|
||||||
|
- Domain validation (rejects spoofed tracking IDs)
|
||||||
|
- IP geolocation → country, region, city on Visitor
|
||||||
|
- CORS middleware for cross-origin beacons
|
||||||
|
- Tracking pixel (1×1 GIF) as no-JS fallback
|
||||||
|
- Rate limiting (120 req/min per IP)
|
||||||
|
- Ad-blocker resistant alternate endpoints (`/cdn/feedback`, `/cdn/resources/`)
|
||||||
|
|
||||||
|
**Dashboard**
|
||||||
|
- Date-range picker (today / 7d / 30d / custom)
|
||||||
|
- Metric cards: page views, unique visitors, sessions, bounce rate, avg session duration, pages/session
|
||||||
|
- Visitors & page views chart (daily/hourly granularity)
|
||||||
|
- Real-time active visitors widget (last 5 minutes)
|
||||||
|
- Comparison mode vs. previous period with delta %
|
||||||
|
- Goal conversion sparklines
|
||||||
|
|
||||||
|
**Reports — Audience**
|
||||||
|
- Geographic: visitors by country, region, city (table + world map)
|
||||||
|
- Technology: browser, OS, device type, screen resolution
|
||||||
|
- Language: browser language breakdown
|
||||||
|
- New vs. returning visitors
|
||||||
|
|
||||||
|
**Reports — Acquisition**
|
||||||
|
- Traffic sources (direct, organic, referral, social)
|
||||||
|
- UTM campaigns (source, medium, campaign)
|
||||||
|
- Top referrers
|
||||||
|
|
||||||
|
**Reports — Behavior**
|
||||||
|
- Top pages (views, unique views, avg time, bounce rate)
|
||||||
|
- Landing pages (entry pages with session count and bounce rate)
|
||||||
|
- Exit pages
|
||||||
|
|
||||||
|
**Reports — Events**
|
||||||
|
- Events overview (grouped by name/category/action)
|
||||||
|
- Event detail drill-down (label/value breakdown)
|
||||||
|
- Event trending chart
|
||||||
|
|
||||||
|
**Reports — Conversions**
|
||||||
|
- Goal completions, conversion rate, goal value totals
|
||||||
|
|
||||||
|
**Goals & Conversions**
|
||||||
|
- 4 goal types: URL destination, event match, duration, pages per session
|
||||||
|
- GoalEvaluationService — evaluates goals on every incoming payload
|
||||||
|
- Conversion rate reporting
|
||||||
|
|
||||||
|
**Advanced Features**
|
||||||
|
- Funnels — multi-step conversion tracking with drop-off visualization
|
||||||
|
- Segments — filter all reports by visitor attributes
|
||||||
|
- Custom dashboards — pin widgets to a personal overview
|
||||||
|
- Alerts — notify on traffic spikes/drops/goal thresholds (email + in-app)
|
||||||
|
- Annotations — add notes to specific dates on charts
|
||||||
|
- Data export — CSV export for any report
|
||||||
|
- UTM builder — in-app campaign URL generator
|
||||||
|
|
||||||
|
**API (v1)**
|
||||||
|
- `GET /api/v1/websites` — list websites
|
||||||
|
- `GET /api/v1/websites/{id}/stats` — summary metrics with date-range params
|
||||||
|
- `GET /api/v1/websites/{id}/pages` — top pages
|
||||||
|
- `GET /api/v1/websites/{id}/referrers` — traffic sources
|
||||||
|
- `GET /api/v1/websites/{id}/events` — events
|
||||||
|
- `GET /api/v1/websites/{id}/visitors` — audience breakdown
|
||||||
|
- JSON:API resource classes for consistent formatting
|
||||||
|
- Rate limiting per token
|
||||||
|
|
||||||
|
**Data Aggregation**
|
||||||
|
- `daily_aggregates` table — pre-computed per-website daily metrics
|
||||||
|
- `page_daily_aggregates` table — per-page daily metrics
|
||||||
|
- `analytics:aggregate` Artisan command (runs daily at 02:00)
|
||||||
|
- Hybrid query strategy: aggregates for past days, raw tables for today
|
||||||
|
- `analytics:purge` command — purge raw data older than retention window
|
||||||
|
- Configurable `data_retention_days` per website (default: 365)
|
||||||
|
|
||||||
|
**Performance & Indexes**
|
||||||
|
- Composite indexes on all date-range + website_id query paths
|
||||||
|
- SQLite-compatible index migrations (also works with MySQL/PostgreSQL)
|
||||||
|
|
||||||
|
**Testing**
|
||||||
|
- 80 tests, 0 failures
|
||||||
|
- Feature tests: `CollectController`, `WebsiteController`, Auth flows
|
||||||
|
- Unit tests: `AnalyticsQueryService`, `GoalEvaluationService`
|
||||||
|
- Model factories for all analytics models
|
||||||
|
- `withoutVite()` in base TestCase — tests run without a built frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔲 Not Yet Done
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **User Flow / Sankey diagram** | Intentionally deferred — complex to visualize, low priority for v1 |
|
||||||
|
| **Production database** | SQLite is dev-only; set up MySQL or PostgreSQL before launch |
|
||||||
|
| **Redis + Horizon** | Replace database queue driver for real traffic; Horizon gives a monitoring UI |
|
||||||
|
| **Cron / scheduler** | Configure `schedule:run` on the server — needed for aggregation, purge, and alerts |
|
||||||
|
| **HTTPS + CDN for tracker JS** | Tracker script should load from a CDN subdomain to avoid ad-blockers |
|
||||||
|
| **Error monitoring** | Wire up Sentry or Flare for production error tracking |
|
||||||
|
| **Database backups** | Automate daily dumps with offsite storage |
|
||||||
|
| **Dusk browser tests** | UI interaction tests deferred — requires Dusk + ChromeDriver setup |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/analytics
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
php artisan migrate
|
||||||
|
npm install && npm run build
|
||||||
|
composer run dev # starts web server + queue worker + log viewer + Vite
|
||||||
|
```
|
||||||
|
|
||||||
|
App runs at `http://localhost:8000`.
|
||||||
|
|
||||||
|
### Background processes required
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Queue worker — processes tracking events (critical)
|
||||||
|
php artisan queue:work database --tries=3
|
||||||
|
|
||||||
|
# Scheduler — aggregation, purge, alerts (critical)
|
||||||
|
php artisan schedule:work
|
||||||
|
|
||||||
|
# OR add to crontab for production:
|
||||||
|
# * * * * * cd /path/to/analytics && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] Provision server (Ubuntu 22+ recommended)
|
||||||
|
- [ ] Install PHP 8.2+, Nginx, MySQL/PostgreSQL, Redis, Node
|
||||||
|
- [ ] Clone repo, `composer install --no-dev`, `npm ci && npm run build`
|
||||||
|
- [ ] Set `APP_ENV=production`, `APP_DEBUG=false`, configure `.env`
|
||||||
|
- [ ] Run `php artisan migrate --force`
|
||||||
|
- [ ] Run `php artisan config:cache route:cache view:cache`
|
||||||
|
- [ ] Configure Supervisor for queue worker (see `HOWTO.md`)
|
||||||
|
- [ ] Add crontab entry for `schedule:run`
|
||||||
|
- [ ] Point Nginx document root to `public/`
|
||||||
|
- [ ] Set up SSL (Let's Encrypt)
|
||||||
|
- [ ] Set up CNAME for tracker script (e.g. `track.yourdomain.com`)
|
||||||
|
- [ ] Configure Sentry DSN in `.env`
|
||||||
|
- [ ] Set up automated database backups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Artisan Commands
|
||||||
|
|
||||||
|
| Command | Description | Schedule |
|
||||||
|
|---|---|---|
|
||||||
|
| `analytics:aggregate` | Roll up yesterday's raw data into aggregates | Daily 02:00 |
|
||||||
|
| `analytics:purge --force` | Delete raw data older than retention window | Daily 03:00 |
|
||||||
|
| `analytics:check-alerts` | Evaluate alert rules and send notifications | Every hour |
|
||||||
|
| `queue:work database --tries=3` | Process tracking payloads | Always-on process |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
Client website (JS snippet)
|
||||||
|
→ POST /api/e (CollectController)
|
||||||
|
→ ProcessTrackingPayload job (queued)
|
||||||
|
→ Creates/updates: Visitor, AnalyticsSession, PageView or Event
|
||||||
|
→ GoalEvaluationService checks active goals
|
||||||
|
→ Daily: analytics:aggregate rolls raw → daily_aggregates
|
||||||
|
→ Dashboard queries: aggregates for history + raw tables for today
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collector Extraction (future)
|
||||||
|
The `collector_app_plan.md` documents a plan to split the data collection endpoints into a separate Laravel app for independent scaling. This is not needed yet but the code is structured to make it straightforward — `CollectController`, `TrackingScriptController`, `ProcessTrackingPayload`, and `GoalEvaluationService` are all self-contained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema (Core Tables)
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `websites` | Tracked websites, settings, tracking ID |
|
||||||
|
| `visitors` | Unique visitors with device, browser, location |
|
||||||
|
| `analytics_sessions` | Sessions with UTM params, entry/exit page, duration |
|
||||||
|
| `page_views` | Individual page visits |
|
||||||
|
| `events` | Custom events with category, label, value |
|
||||||
|
| `goals` | Conversion goal definitions (4 types) |
|
||||||
|
| `goal_conversions` | Goal completion records |
|
||||||
|
| `funnels` | Multi-step funnel definitions |
|
||||||
|
| `segments` | Custom visitor segment definitions |
|
||||||
|
| `alerts` | Alert rule definitions |
|
||||||
|
| `alert_notifications` | Triggered alert history |
|
||||||
|
| `annotations` | Date annotations visible on charts |
|
||||||
|
| `dashboard_widgets` | Custom dashboard layout per user |
|
||||||
|
| `daily_aggregates` | Pre-computed daily metrics per website |
|
||||||
|
| `page_daily_aggregates` | Pre-computed daily metrics per page |
|
||||||
@@ -80,16 +80,16 @@ class AggregateAnalytics extends Command
|
|||||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$pageViews = PageView::where('website_id', $website->id)
|
$page_views = PageView::where('website_id', $website->id)
|
||||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$bounceCount = AnalyticsSession::where('website_id', $website->id)
|
$bounce_count = AnalyticsSession::where('website_id', $website->id)
|
||||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||||
->where('is_bounce', true)
|
->where('is_bounce', true)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$totalDuration = (int) AnalyticsSession::where('website_id', $website->id)
|
$total_duration = (int) AnalyticsSession::where('website_id', $website->id)
|
||||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||||
->sum('duration');
|
->sum('duration');
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class CheckAlerts extends Command
|
|||||||
$triggered = 0;
|
$triggered = 0;
|
||||||
|
|
||||||
foreach ($alerts as $alert) {
|
foreach ($alerts as $alert) {
|
||||||
|
// Skip if triggered recently (within the period to avoid spam)
|
||||||
|
if ($this->wasRecentlyTriggered($alert)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$currentValue = $this->getCurrentValue($alert);
|
$currentValue = $this->getCurrentValue($alert);
|
||||||
$shouldTrigger = $this->evaluate($alert, $currentValue);
|
$shouldTrigger = $this->evaluate($alert, $currentValue);
|
||||||
|
|
||||||
@@ -40,10 +45,10 @@ class CheckAlerts extends Command
|
|||||||
|
|
||||||
private function getCurrentValue(Alert $alert): float
|
private function getCurrentValue(Alert $alert): float
|
||||||
{
|
{
|
||||||
$to = Carbon::today();
|
$to = Carbon::now();
|
||||||
$from = $alert->period === 'hourly'
|
$from = $alert->period === 'hourly'
|
||||||
? Carbon::now()->subHour()
|
? Carbon::now()->subHour()
|
||||||
: Carbon::yesterday();
|
: Carbon::today()->startOfDay();
|
||||||
|
|
||||||
$service = AnalyticsQueryService::for($alert->website, $from, $to);
|
$service = AnalyticsQueryService::for($alert->website, $from, $to);
|
||||||
$summary = $service->summary();
|
$summary = $service->summary();
|
||||||
@@ -70,13 +75,25 @@ class CheckAlerts extends Command
|
|||||||
|
|
||||||
private function percentageChange(Alert $alert): float
|
private function percentageChange(Alert $alert): float
|
||||||
{
|
{
|
||||||
$to = Carbon::today();
|
if ($alert->period === 'hourly') {
|
||||||
$from = Carbon::yesterday();
|
// Compare current hour with previous hour
|
||||||
$prevFrom = Carbon::yesterday()->subDay();
|
$currentTo = Carbon::now();
|
||||||
$prevTo = Carbon::yesterday();
|
$currentFrom = Carbon::now()->subHour();
|
||||||
|
$prevTo = Carbon::now()->subHour();
|
||||||
|
$prevFrom = Carbon::now()->subHours(2);
|
||||||
|
|
||||||
$current = $this->getValueForPeriod($alert, $from, $to);
|
$current = $this->getValueForPeriod($alert, $currentFrom, $currentTo);
|
||||||
$previous = $this->getValueForPeriod($alert, $prevFrom, $prevTo);
|
$previous = $this->getValueForPeriod($alert, $prevFrom, $prevTo);
|
||||||
|
} else {
|
||||||
|
// Compare today so far with yesterday same time
|
||||||
|
$currentTo = Carbon::now();
|
||||||
|
$currentFrom = Carbon::today()->startOfDay();
|
||||||
|
$prevTo = Carbon::yesterday()->setTime($currentTo->hour, $currentTo->minute, $currentTo->second);
|
||||||
|
$prevFrom = Carbon::yesterday()->startOfDay();
|
||||||
|
|
||||||
|
$current = $this->getValueForPeriod($alert, $currentFrom, $currentTo);
|
||||||
|
$previous = $this->getValueForPeriod($alert, $prevFrom, $prevTo);
|
||||||
|
}
|
||||||
|
|
||||||
if ($previous == 0) {
|
if ($previous == 0) {
|
||||||
return $current > 0 ? 100 : 0;
|
return $current > 0 ? 100 : 0;
|
||||||
@@ -93,6 +110,18 @@ class CheckAlerts extends Command
|
|||||||
return $summary[$alert->metric] ?? 0;
|
return $summary[$alert->metric] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function wasRecentlyTriggered(Alert $alert): bool
|
||||||
|
{
|
||||||
|
if (!$alert->last_triggered_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cooldown = $alert->period === 'hourly' ? 1 : 24; // hours
|
||||||
|
$threshold = Carbon::now()->subHours($cooldown);
|
||||||
|
|
||||||
|
return $alert->last_triggered_at->isAfter($threshold);
|
||||||
|
}
|
||||||
|
|
||||||
private function trigger(Alert $alert, float $currentValue): void
|
private function trigger(Alert $alert, float $currentValue): void
|
||||||
{
|
{
|
||||||
$conditionLabel = Alert::$conditions[$alert->condition] ?? $alert->condition;
|
$conditionLabel = Alert::$conditions[$alert->condition] ?? $alert->condition;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class CollectController extends Controller
|
|||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
if ($this->isBot($request)) {
|
if ($this->isBot($request)) {
|
||||||
$this->log("ISBOT");
|
|
||||||
return response()->json(['ok' => false, 'reason' => 'bot'], 200);
|
return response()->json(['ok' => false, 'reason' => 'bot'], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,24 +58,14 @@ class CollectController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $this->isDomainValid($payload)) {
|
if (! $this->isDomainValid($payload)) {
|
||||||
$this->log("Domain is invalid");
|
|
||||||
return response()->json(['ok' => false, 'reason' => 'domain_mismatch'], 400);
|
return response()->json(['ok' => false, 'reason' => 'domain_mismatch'], 400);
|
||||||
}
|
}
|
||||||
$this->log("dispatch job");
|
|
||||||
ProcessTrackingPayload::dispatch($payload, $request->ip(), Carbon::now());
|
ProcessTrackingPayload::dispatch($payload, $request->ip(), Carbon::now());
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function log($message)
|
|
||||||
{
|
|
||||||
$path = '/home/libremet/domains/libremetrics.com/engine/storage/logs/mihai.log';
|
|
||||||
|
|
||||||
$message = date('Y-m-d H:i:s') . "\n" . $message . "\n\n";
|
|
||||||
|
|
||||||
file_put_contents($path, $message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1x1 transparent GIF tracking pixel for no-JS fallback.
|
* 1x1 transparent GIF tracking pixel for no-JS fallback.
|
||||||
* Usage: <img src="/api/pixel?tid=TA-XXX" width="1" height="1" alt="">
|
* Usage: <img src="/api/pixel?tid=TA-XXX" width="1" height="1" alt="">
|
||||||
|
|||||||
@@ -9,37 +9,30 @@ class TrackingScriptController extends Controller
|
|||||||
{
|
{
|
||||||
public function script(string $trackingId): Response
|
public function script(string $trackingId): Response
|
||||||
{
|
{
|
||||||
$this->log("Start ".$trackingId);
|
|
||||||
$website = Website::where('tracking_id', $trackingId)
|
$website = Website::where('tracking_id', $trackingId)
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $website) {
|
if (! $website) {
|
||||||
$this->log("no website ".$trackingId);
|
|
||||||
return response('', 404)
|
return response('', 404)
|
||||||
->header('Content-Type', 'application/javascript');
|
->header('Content-Type', 'application/javascript');
|
||||||
}
|
}
|
||||||
|
|
||||||
$collectUrl = route('collect.store');
|
// Use ad-blocker resistant endpoint if script is loaded from /cdn/resources/
|
||||||
|
// Otherwise use the original endpoint for backward compatibility
|
||||||
|
$currentRoute = request()->route()->getName();
|
||||||
|
// $collectUrl = $currentRoute === 'tracking.script.alt'
|
||||||
|
// ? 'route('collect.store.alt')'
|
||||||
|
// : route('collect.store');
|
||||||
|
$collectUrl = 'https://assets.libremetrics.com/assets/client/data';
|
||||||
|
|
||||||
$js = view('tracking.tracker', [
|
$js = view('tracking.tracker', [
|
||||||
'trackingId' => $trackingId,
|
'trackingId' => $trackingId,
|
||||||
'collectUrl' => $collectUrl,
|
'collectUrl' => $collectUrl,
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
$this->log("response ".$trackingId);
|
|
||||||
|
|
||||||
return response($js)
|
return response($js)
|
||||||
->header('Content-Type', 'application/javascript')
|
->header('Content-Type', 'application/javascript')
|
||||||
->header('Cache-Control', 'public, max-age=3600');
|
->header('Cache-Control', 'public, max-age=3600');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function log($message)
|
|
||||||
{
|
|
||||||
$path = '/home/libremet/domains/libremetrics.com/engine/storage/logs/mihai.log';
|
|
||||||
|
|
||||||
$message = date('Y-m-d H:i:s') . "\n" . $message . "\n\n";
|
|
||||||
|
|
||||||
file_put_contents($path, $message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,27 @@ class CollectCors
|
|||||||
|
|
||||||
private function corsHeaders(Request $request): array
|
private function corsHeaders(Request $request): array
|
||||||
{
|
{
|
||||||
// Get the origin from the request, default to '*' if not present
|
// Get the origin from the request
|
||||||
$origin = $request->header('Origin', '*');
|
$origin = $request->header('Origin');
|
||||||
|
|
||||||
|
// If origin is present, allow it with credentials
|
||||||
|
// Otherwise, use wildcard without credentials
|
||||||
|
if ($origin) {
|
||||||
return [
|
return [
|
||||||
'Access-Control-Allow-Origin' => $origin,
|
'Access-Control-Allow-Origin' => $origin,
|
||||||
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
||||||
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||||
'Access-Control-Max-Age' => '86400',
|
'Access-Control-Max-Age' => '86400',
|
||||||
'Access-Control-Allow-Credentials' => 'true',
|
'Access-Control-Allow-Credentials' => 'true',
|
||||||
|
'Vary' => 'Origin',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Access-Control-Allow-Origin' => '*',
|
||||||
|
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||||
|
'Access-Control-Max-Age' => '86400',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,49 @@ class StoreWebsiteRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
// Normalize domain: remove protocol and www, trim slashes
|
||||||
|
if ($this->has('domain')) {
|
||||||
|
$domain = $this->input('domain');
|
||||||
|
|
||||||
|
// Remove protocol (http://, https://)
|
||||||
|
$domain = preg_replace('#^https?://#i', '', $domain);
|
||||||
|
|
||||||
|
// Remove www.
|
||||||
|
$domain = preg_replace('#^www\.#i', '', $domain);
|
||||||
|
|
||||||
|
// Remove trailing slashes and paths
|
||||||
|
$domain = strtok($domain, '/');
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
$domain = trim($domain);
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'domain' => $domain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'min:1', 'max:255'],
|
||||||
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/'],
|
'domain' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'regex:/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/',
|
||||||
|
'unique:websites,domain,NULL,id,user_id,' . $this->user()->id,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'domain.regex' => 'Please enter a valid domain (e.g. example.com).',
|
'domain.regex' => 'Please enter a valid domain (e.g. example.com or subdomain.example.com).',
|
||||||
|
'domain.unique' => 'You already have a website with this domain.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,43 @@ class UpdateWebsiteRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
// Normalize domain: remove protocol and www, trim slashes
|
||||||
|
if ($this->has('domain')) {
|
||||||
|
$domain = $this->input('domain');
|
||||||
|
|
||||||
|
// Remove protocol (http://, https://)
|
||||||
|
$domain = preg_replace('#^https?://#i', '', $domain);
|
||||||
|
|
||||||
|
// Remove www.
|
||||||
|
$domain = preg_replace('#^www\.#i', '', $domain);
|
||||||
|
|
||||||
|
// Remove trailing slashes and paths
|
||||||
|
$domain = strtok($domain, '/');
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
$domain = trim($domain);
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'domain' => $domain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$websiteId = $this->route('website')->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'min:1', 'max:255'],
|
||||||
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/'],
|
'domain' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'regex:/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/',
|
||||||
|
'unique:websites,domain,' . $websiteId . ',id,user_id,' . $this->user()->id,
|
||||||
|
],
|
||||||
'is_active' => ['sometimes', 'boolean'],
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -23,7 +55,8 @@ class UpdateWebsiteRequest extends FormRequest
|
|||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'domain.regex' => 'Please enter a valid domain (e.g. example.com).',
|
'domain.regex' => 'Please enter a valid domain (e.g. example.com or subdomain.example.com).',
|
||||||
|
'domain.unique' => 'You already have a website with this domain.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,9 +110,14 @@ class ProcessTrackingPayload implements ShouldQueue
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate duration, ensuring it's never negative (handle out-of-order events)
|
||||||
|
// Use max() to ensure we use the latest timestamp for ended_at
|
||||||
|
$endedAt = $session->ended_at ? max($now, $session->ended_at) : $now;
|
||||||
|
$duration = max(0, (int) $endedAt->diffInSeconds($session->started_at, false));
|
||||||
|
|
||||||
$session->update([
|
$session->update([
|
||||||
'ended_at' => $now,
|
'ended_at' => $endedAt,
|
||||||
'duration' => $now->diffInSeconds($session->started_at),
|
'duration' => $duration,
|
||||||
'exit_page' => $this->payload['path'] ?? $this->payload['url'] ?? $session->exit_page,
|
'exit_page' => $this->payload['path'] ?? $this->payload['url'] ?? $session->exit_page,
|
||||||
'pages_count' => $session->pageViews()->count(),
|
'pages_count' => $session->pageViews()->count(),
|
||||||
'is_bounce' => $session->pageViews()->count() <= 1,
|
'is_bounce' => $session->pageViews()->count() <= 1,
|
||||||
|
|||||||
+35
-1
@@ -16,6 +16,7 @@ class Website extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'name',
|
'name',
|
||||||
'domain',
|
'domain',
|
||||||
|
'slug',
|
||||||
'tracking_id',
|
'tracking_id',
|
||||||
'is_active',
|
'is_active',
|
||||||
'settings',
|
'settings',
|
||||||
@@ -35,9 +36,32 @@ class Website extends Model
|
|||||||
if (empty($website->tracking_id)) {
|
if (empty($website->tracking_id)) {
|
||||||
$website->tracking_id = 'TA-' . strtoupper(Str::random(10));
|
$website->tracking_id = 'TA-' . strtoupper(Str::random(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-generate slug from name if not provided
|
||||||
|
if (empty($website->slug)) {
|
||||||
|
$baseSlug = Str::slug($website->name);
|
||||||
|
$slug = $baseSlug;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
// Ensure slug uniqueness
|
||||||
|
while (static::where('slug', $slug)->exists()) {
|
||||||
|
$slug = $baseSlug . '-' . $counter;
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$website->slug = $slug;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the route key for the model.
|
||||||
|
*/
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'slug';
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
@@ -115,11 +139,21 @@ class Website extends Model
|
|||||||
|
|
||||||
public function embedSnippet(): string
|
public function embedSnippet(): string
|
||||||
{
|
{
|
||||||
$scriptUrl = route('tracking.script', $this->tracking_id);
|
$scriptUrl = route('tracking.script.alt', $this->tracking_id);
|
||||||
|
|
||||||
return <<<HTML
|
return <<<HTML
|
||||||
<!-- Analytics Tracker -->
|
<!-- Analytics Tracker -->
|
||||||
<script defer src="{$scriptUrl}"></script>
|
<script defer src="{$scriptUrl}"></script>
|
||||||
HTML;
|
HTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function embedSnippetLegacy(): string
|
||||||
|
{
|
||||||
|
$scriptUrl = route('tracking.script', $this->tracking_id);
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<!-- Analytics Tracker (Legacy) -->
|
||||||
|
<script defer src="{$scriptUrl}"></script>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,843 @@
|
|||||||
|
# Analytics Collector App Extraction Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the plan for extracting the data collection endpoints from the main analytics application into a separate Laravel application. The collector app will be responsible for receiving tracking data from external websites, processing it, and storing it in the **shared database** (same database as the main app).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Separation of Concerns**: Isolate high-traffic data collection endpoints from the analytics dashboard
|
||||||
|
2. **Scalability**: Enable independent scaling of the collector service
|
||||||
|
3. **Reliability**: Prevent tracking service disruptions from affecting dashboard availability
|
||||||
|
4. **Performance**: Optimize collector for high-throughput, low-latency operations
|
||||||
|
5. **Security**: Isolate public-facing endpoints from internal analytics data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Single Laravel Application │
|
||||||
|
│ ┌─────────────┐ ┌────────────────┐ │
|
||||||
|
│ │ Dashboard │ │ Collection │ │
|
||||||
|
│ │ (Auth UI) │ │ Endpoints │ │
|
||||||
|
│ └─────────────┘ └────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────▼─────┐ │
|
||||||
|
│ │ Database │ │
|
||||||
|
│ └───────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target State (Shared Database Architecture)
|
||||||
|
```
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Analytics App │ │ Collector App │
|
||||||
|
│ (Dashboard/Auth) │ │ (Data Ingestion) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────────────┐ │ │ ┌────────────────┐ │
|
||||||
|
│ │ Reports/UI │ │ │ │ POST /api/e │ │
|
||||||
|
│ │ User Mgmt │ │ │ │ GET /api/p │ │
|
||||||
|
│ │ Settings │ │ │ │ Tracking JS │ │
|
||||||
|
│ └────────────────┘ │ │ └────────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └───────────┴───────┴──────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────▼────────┐ │
|
||||||
|
│ │ Shared Database│ │
|
||||||
|
│ │ (All tables) │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Both apps read/write to the same database:
|
||||||
|
- No data sync needed
|
||||||
|
- Real-time data availability
|
||||||
|
- Simpler architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup New Collector App
|
||||||
|
|
||||||
|
### 1.1 Create New Laravel Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer create-project laravel/laravel analytics-collector
|
||||||
|
cd analytics-collector
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Configure Environment
|
||||||
|
|
||||||
|
**`.env` configuration for collector app:**
|
||||||
|
```env
|
||||||
|
APP_NAME="Analytics Collector"
|
||||||
|
|
||||||
|
# IMPORTANT: Same database as main app
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1 # Same as main app
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=analytics # Same database name as main app
|
||||||
|
DB_USERNAME=same_user_as_main_app
|
||||||
|
DB_PASSWORD=same_password_as_main_app
|
||||||
|
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
|
||||||
|
# Rate limiting - can be higher than main app
|
||||||
|
COLLECTOR_RATE_LIMIT=120 # per minute per IP
|
||||||
|
|
||||||
|
# Geolocation
|
||||||
|
LOCATION_TESTING=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Database credentials must match the main app exactly
|
||||||
|
- Both apps will share all tables (websites, visitors, sessions, events, etc.)
|
||||||
|
- No separate database needed
|
||||||
|
- No data sync mechanism required
|
||||||
|
|
||||||
|
### 1.3 Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require jaybizzle/crawler-detect
|
||||||
|
composer require stevebauman/location
|
||||||
|
composer require predis/predis
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Database Setup (Shared Database)
|
||||||
|
|
||||||
|
### 2.1 No Migrations Needed!
|
||||||
|
|
||||||
|
Since both apps use the **same database**, there's no need to copy or create migrations in the collector app.
|
||||||
|
|
||||||
|
**What this means:**
|
||||||
|
- The database already has all necessary tables from the main app
|
||||||
|
- The collector app will simply connect to the existing database
|
||||||
|
- Both apps read/write to the same tables directly
|
||||||
|
- No schema changes required
|
||||||
|
|
||||||
|
### 2.2 Skip Running Migrations
|
||||||
|
|
||||||
|
**Do NOT run migrations on the collector app** - the tables already exist from the main app.
|
||||||
|
|
||||||
|
Instead:
|
||||||
|
1. Configure `.env` to point to the same database
|
||||||
|
2. Test database connection: `php artisan db:show`
|
||||||
|
3. Verify tables exist: `php artisan tinker` → `DB::table('websites')->count()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Code Extraction
|
||||||
|
|
||||||
|
### 3.1 Controllers to Copy
|
||||||
|
|
||||||
|
**File: `app/Http/Controllers/CollectController.php`**
|
||||||
|
|
||||||
|
Copy entire controller with modifications:
|
||||||
|
- Keep all validation logic
|
||||||
|
- Keep bot detection (`isBot()`, `isDomainValid()`)
|
||||||
|
- Dispatch `ProcessTrackingPayload` job
|
||||||
|
|
||||||
|
**File: `app/Http/Controllers/TrackingScriptController.php`**
|
||||||
|
|
||||||
|
Copy entire controller:
|
||||||
|
- Serves JavaScript tracking code
|
||||||
|
- Update collection endpoint URLs to point to collector app
|
||||||
|
|
||||||
|
### 3.2 Middleware to Copy
|
||||||
|
|
||||||
|
**File: `app/Http/Middleware/CollectCors.php`**
|
||||||
|
|
||||||
|
Copy entire middleware for CORS handling.
|
||||||
|
|
||||||
|
**Register in `bootstrap/app.php`:**
|
||||||
|
```php
|
||||||
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
$middleware->alias([
|
||||||
|
'collect.cors' => \App\Http\Middleware\CollectCors::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Jobs to Copy
|
||||||
|
|
||||||
|
**File: `app/Jobs/ProcessTrackingPayload.php`**
|
||||||
|
|
||||||
|
Copy entire job with modifications:
|
||||||
|
- Keep all visitor/session/pageview/event processing
|
||||||
|
- Keep goal evaluation logic
|
||||||
|
- Add error handling for missing websites (sync issue)
|
||||||
|
|
||||||
|
### 3.4 Models to Copy
|
||||||
|
|
||||||
|
Copy the following models **exactly as they are** from the main app:
|
||||||
|
- `app/Models/Website.php`
|
||||||
|
- `app/Models/Visitor.php`
|
||||||
|
- `app/Models/AnalyticsSession.php`
|
||||||
|
- `app/Models/PageView.php`
|
||||||
|
- `app/Models/Event.php`
|
||||||
|
- `app/Models/Goal.php`
|
||||||
|
- `app/Models/GoalConversion.php`
|
||||||
|
|
||||||
|
**Important notes:**
|
||||||
|
- Copy the models unchanged - they need to work with the same database schema
|
||||||
|
- Keep all relationships intact
|
||||||
|
- Keep all casts and accessors
|
||||||
|
- The only exception: You can remove the `User` relationship from `Website.php` if the collector doesn't need it
|
||||||
|
|
||||||
|
### 3.5 Services to Copy
|
||||||
|
|
||||||
|
**File: `app/Services/GoalEvaluationService.php`**
|
||||||
|
|
||||||
|
Copy entire service for goal evaluation logic.
|
||||||
|
|
||||||
|
### 3.6 Views to Copy
|
||||||
|
|
||||||
|
**File: `resources/views/tracking/tracker.blade.php`**
|
||||||
|
|
||||||
|
Copy JavaScript tracking code with modifications:
|
||||||
|
- Update collection endpoint URLs to collector app
|
||||||
|
- Keep all tracking logic (visitor ID, session management, event tracking)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Routes Configuration
|
||||||
|
|
||||||
|
### 4.1 API Routes
|
||||||
|
|
||||||
|
**File: `routes/api.php`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Http\Controllers\CollectController;
|
||||||
|
use App\Http\Controllers\TrackingScriptController;
|
||||||
|
|
||||||
|
// Main collection endpoints (backward compatible)
|
||||||
|
Route::post('/e', [CollectController::class, 'store'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.store');
|
||||||
|
|
||||||
|
Route::get('/p', [CollectController::class, 'pixel'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.pixel');
|
||||||
|
|
||||||
|
// Ad-blocker resistant endpoints
|
||||||
|
Route::post('/cdn/feedback', [CollectController::class, 'store'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.store.alt');
|
||||||
|
|
||||||
|
Route::get('/cdn/status.gif', [CollectController::class, 'pixel'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.pixel.alt');
|
||||||
|
|
||||||
|
// Tracking script delivery
|
||||||
|
Route::get('/t/{trackingId}.js', [TrackingScriptController::class, 'script'])
|
||||||
|
->middleware('collect.cors')
|
||||||
|
->name('tracking.script');
|
||||||
|
|
||||||
|
Route::get('/cdn/resources/{trackingId}.js', [TrackingScriptController::class, 'script'])
|
||||||
|
->middleware('collect.cors')
|
||||||
|
->name('tracking.script.alt');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Rate Limiting Configuration
|
||||||
|
|
||||||
|
**File: `app/Providers/AppServiceProvider.php`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('collect', function (Request $request) {
|
||||||
|
return Limit::perMinute(config('collector.rate_limit', 120))
|
||||||
|
->by($request->ip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create config file: `config/collector.php`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'rate_limit' => env('COLLECTOR_RATE_LIMIT', 120),
|
||||||
|
'main_app_url' => env('MAIN_APP_URL'),
|
||||||
|
'api_key' => env('COLLECTOR_API_KEY'),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: No Sync Mechanism Needed! 🎉
|
||||||
|
|
||||||
|
### 5.1 Shared Database = No Sync Required
|
||||||
|
|
||||||
|
Since both apps use the **same database**, there's **NO need for a sync mechanism**!
|
||||||
|
|
||||||
|
**What this means:**
|
||||||
|
- When a website is created/updated/deleted in the main app, it's immediately available in the collector app
|
||||||
|
- When a goal is created/updated/deleted in the main app, the collector app sees it instantly
|
||||||
|
- When the collector creates tracking data, the main app can query it immediately
|
||||||
|
- Zero latency between apps
|
||||||
|
- No complex sync logic needed
|
||||||
|
- No API endpoints for syncing
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Simpler codebase (no sync controllers, middleware, or API calls)
|
||||||
|
- Real-time data consistency
|
||||||
|
- No sync delays or failures to handle
|
||||||
|
- Reduced network overhead
|
||||||
|
- Fewer moving parts = fewer potential issues
|
||||||
|
|
||||||
|
**Only consideration:**
|
||||||
|
- Both apps must have read access to `websites` and `goals` tables
|
||||||
|
- Both apps need write access to `visitors`, `sessions`, `page_views`, `events`, `goal_conversions` tables
|
||||||
|
- Database connection pooling should account for connections from both apps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Data Flow - Already Solved! ✅
|
||||||
|
|
||||||
|
### Shared Database Architecture
|
||||||
|
|
||||||
|
**Data flow is automatic with shared database:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Collector App Main App
|
||||||
|
│ │
|
||||||
|
│ Writes tracking data │ Reads tracking data
|
||||||
|
│ (visitors, sessions, │ (for reports & analytics)
|
||||||
|
│ page_views, events) │
|
||||||
|
│ │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────┐
|
||||||
|
│ Database │
|
||||||
|
│ (Shared) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. **Collector writes** → tracking data (visitors, sessions, page_views, events, conversions)
|
||||||
|
2. **Main app reads** → same tables for reports, dashboards, analytics
|
||||||
|
3. **Main app writes** → configuration data (websites, goals, users, settings)
|
||||||
|
4. **Collector reads** → website/goal configs for validation and goal evaluation
|
||||||
|
|
||||||
|
**No additional implementation needed** - this is the beauty of the shared database approach!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Deployment Strategy
|
||||||
|
|
||||||
|
### 7.1 Infrastructure Setup
|
||||||
|
|
||||||
|
**Collector App Requirements:**
|
||||||
|
- High-availability load balancer
|
||||||
|
- Multiple app instances for horizontal scaling
|
||||||
|
- Redis for queue and cache
|
||||||
|
- Database (MySQL/PostgreSQL)
|
||||||
|
- CDN for tracking script delivery (optional)
|
||||||
|
|
||||||
|
**Suggested Stack:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ CDN (CloudFlare) │
|
||||||
|
│ /cdn/resources/*.js │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────────────┐
|
||||||
|
│ Load Balancer │
|
||||||
|
│ (nginx/HAProxy) │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
│ │
|
||||||
|
┌───▼───┐ ┌──▼────┐
|
||||||
|
│ App 1 │ │ App 2 │ ... (N instances)
|
||||||
|
└───┬───┘ └──┬────┘
|
||||||
|
│ │
|
||||||
|
┌───▼────────▼───┐
|
||||||
|
│ Redis Queue │
|
||||||
|
└───┬────────────┘
|
||||||
|
│
|
||||||
|
┌───▼────────────┐
|
||||||
|
│ Queue Workers │
|
||||||
|
└───┬────────────┘
|
||||||
|
│
|
||||||
|
┌───▼────────────┐
|
||||||
|
│ Database │
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Migration Steps
|
||||||
|
|
||||||
|
**Step 1: Prepare Collector App**
|
||||||
|
1. Deploy collector app to staging
|
||||||
|
2. Run migrations
|
||||||
|
3. Sync initial website/goal data
|
||||||
|
4. Test collection endpoints
|
||||||
|
|
||||||
|
**Step 2: Gradual Rollout**
|
||||||
|
1. Update tracking script to include feature flag
|
||||||
|
2. Route 10% of traffic to collector app
|
||||||
|
3. Monitor error rates, latency, data accuracy
|
||||||
|
4. Gradually increase to 100%
|
||||||
|
|
||||||
|
**Step 3: DNS/Routing Changes**
|
||||||
|
1. Update tracking script URLs to collector domain
|
||||||
|
2. Setup CNAME: `track.yourdomain.com` → collector app
|
||||||
|
3. Update CORS configuration
|
||||||
|
|
||||||
|
**Step 4: Main App Cleanup**
|
||||||
|
1. Remove collection routes from main app
|
||||||
|
2. Remove `CollectController`, `TrackingScriptController`
|
||||||
|
3. Keep models and migrations (if shared database)
|
||||||
|
4. Archive old code for rollback safety
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Monitoring & Observability
|
||||||
|
|
||||||
|
### 8.1 Metrics to Track
|
||||||
|
|
||||||
|
**Collector App:**
|
||||||
|
- Request rate (req/sec)
|
||||||
|
- Response time (p50, p95, p99)
|
||||||
|
- Error rate by type (bot, validation, domain_mismatch)
|
||||||
|
- Queue depth and processing rate
|
||||||
|
- Database connection pool usage
|
||||||
|
- Bot traffic percentage
|
||||||
|
|
||||||
|
**Dashboard:**
|
||||||
|
```php
|
||||||
|
// app/Http/Controllers/HealthController.php
|
||||||
|
public function stats(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'requests_last_minute' => Cache::get('requests_last_minute', 0),
|
||||||
|
'queue_depth' => Queue::size(),
|
||||||
|
'db_connections' => DB::connection()->getDoctrineConnection()->getWrappedConnection()->getAttribute(PDO::ATTR_SERVER_INFO),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Alerting
|
||||||
|
|
||||||
|
Set up alerts for:
|
||||||
|
- Response time > 200ms (p95)
|
||||||
|
- Error rate > 1%
|
||||||
|
- Queue depth > 10,000
|
||||||
|
- Database connections > 80% of pool
|
||||||
|
- Disk space < 20%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Performance Optimizations
|
||||||
|
|
||||||
|
### 9.1 Database Optimizations
|
||||||
|
|
||||||
|
**Indexing Strategy:**
|
||||||
|
```sql
|
||||||
|
-- Visitors table
|
||||||
|
CREATE INDEX idx_visitors_lookup ON visitors(website_id, uid);
|
||||||
|
CREATE INDEX idx_visitors_last_seen ON visitors(website_id, last_seen_at);
|
||||||
|
|
||||||
|
-- Sessions table
|
||||||
|
CREATE INDEX idx_sessions_lookup ON analytics_sessions(website_id, uid);
|
||||||
|
CREATE INDEX idx_sessions_started ON analytics_sessions(website_id, started_at);
|
||||||
|
|
||||||
|
-- Page views table
|
||||||
|
CREATE INDEX idx_pageviews_viewed ON page_views(website_id, viewed_at);
|
||||||
|
CREATE INDEX idx_pageviews_path ON page_views(website_id, path(255));
|
||||||
|
|
||||||
|
-- Events table
|
||||||
|
CREATE INDEX idx_events_occurred ON events(website_id, occurred_at);
|
||||||
|
CREATE INDEX idx_events_name ON events(website_id, name);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Partitioning (for very high volume):**
|
||||||
|
```sql
|
||||||
|
-- Partition page_views by month
|
||||||
|
ALTER TABLE page_views PARTITION BY RANGE (YEAR(viewed_at) * 100 + MONTH(viewed_at)) (
|
||||||
|
PARTITION p202603 VALUES LESS THAN (202604),
|
||||||
|
PARTITION p202604 VALUES LESS THAN (202605),
|
||||||
|
-- ... add future partitions
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Queue Optimization
|
||||||
|
|
||||||
|
**Use Redis for queue:**
|
||||||
|
```env
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
REDIS_QUEUE=tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tune worker count based on load:**
|
||||||
|
```bash
|
||||||
|
php artisan queue:work redis --queue=tracking --sleep=1 --tries=3 --max-jobs=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Horizon for queue management:**
|
||||||
|
```bash
|
||||||
|
composer require laravel/horizon
|
||||||
|
php artisan horizon:install
|
||||||
|
php artisan horizon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Caching Strategy
|
||||||
|
|
||||||
|
**Cache website configs:**
|
||||||
|
```php
|
||||||
|
// In ProcessTrackingPayload job
|
||||||
|
protected function getWebsite(string $trackingId): ?Website
|
||||||
|
{
|
||||||
|
return Cache::remember(
|
||||||
|
"website:{$trackingId}",
|
||||||
|
now()->addHours(1),
|
||||||
|
fn() => Website::where('tracking_id', $trackingId)->first()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache goals:**
|
||||||
|
```php
|
||||||
|
protected function getActiveGoals(int $websiteId): Collection
|
||||||
|
{
|
||||||
|
return Cache::remember(
|
||||||
|
"goals:{$websiteId}",
|
||||||
|
now()->addMinutes(5),
|
||||||
|
fn() => Goal::where('website_id', $websiteId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: Security Considerations
|
||||||
|
|
||||||
|
### 10.1 DDoS Protection
|
||||||
|
|
||||||
|
**Rate Limiting by IP:**
|
||||||
|
- Current: 120 req/min per IP
|
||||||
|
- Consider: Dynamic rate limiting based on traffic patterns
|
||||||
|
- Use CloudFlare or similar CDN for DDoS protection
|
||||||
|
|
||||||
|
**Bot Detection:**
|
||||||
|
- Keep `CrawlerDetect` package
|
||||||
|
- Log bot patterns for analysis
|
||||||
|
- Consider adding honeypot endpoints
|
||||||
|
|
||||||
|
### 10.2 Data Validation
|
||||||
|
|
||||||
|
**Strict input validation:**
|
||||||
|
- Validate all tracking payload fields
|
||||||
|
- Sanitize URLs, titles, event names
|
||||||
|
- Limit string lengths (prevent database bloat)
|
||||||
|
- Validate JSON structure for event_properties
|
||||||
|
|
||||||
|
### 10.3 API Security
|
||||||
|
|
||||||
|
**Sync endpoint protection:**
|
||||||
|
- Use strong API keys (32+ characters)
|
||||||
|
- Rotate keys periodically
|
||||||
|
- IP whitelist for sync endpoints
|
||||||
|
- Use HTTPS only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Testing Strategy
|
||||||
|
|
||||||
|
### 11.1 Unit Tests
|
||||||
|
|
||||||
|
**Test coverage for:**
|
||||||
|
- `CollectController` validation logic
|
||||||
|
- `ProcessTrackingPayload` job
|
||||||
|
- `GoalEvaluationService`
|
||||||
|
- Bot detection
|
||||||
|
- Domain validation
|
||||||
|
|
||||||
|
### 11.2 Integration Tests
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- End-to-end pageview tracking
|
||||||
|
- Event tracking and goal conversion
|
||||||
|
- Session management
|
||||||
|
- Website sync from main app
|
||||||
|
- Goal sync from main app
|
||||||
|
|
||||||
|
### 11.3 Load Testing
|
||||||
|
|
||||||
|
**Use tools:**
|
||||||
|
- Apache Bench (ab)
|
||||||
|
- wrk
|
||||||
|
- k6
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
```bash
|
||||||
|
# Simulate 1000 req/sec for 60 seconds
|
||||||
|
k6 run --vus 100 --duration 60s load-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Load test script example:**
|
||||||
|
```javascript
|
||||||
|
// load-test.js
|
||||||
|
import http from 'k6/http';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
tid: 'TA-TEST123456',
|
||||||
|
vid: `visitor-${__VU}-${__ITER}`,
|
||||||
|
sid: `session-${__VU}`,
|
||||||
|
type: 'pageview',
|
||||||
|
url: 'https://example.com/test',
|
||||||
|
path: '/test',
|
||||||
|
});
|
||||||
|
|
||||||
|
http.post('http://collector.local/api/e', payload, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12: Rollback Plan
|
||||||
|
|
||||||
|
### 12.1 Rollback Triggers
|
||||||
|
|
||||||
|
Rollback if:
|
||||||
|
- Error rate > 5%
|
||||||
|
- Data loss detected (missing tracking data)
|
||||||
|
- Performance degradation (p95 > 500ms)
|
||||||
|
- Queue backup (depth > 50,000)
|
||||||
|
|
||||||
|
### 12.2 Rollback Steps
|
||||||
|
|
||||||
|
1. **Immediate:**
|
||||||
|
- Update tracking script to point back to main app
|
||||||
|
- Disable collector app routing
|
||||||
|
|
||||||
|
2. **Within 1 hour:**
|
||||||
|
- Restore collection endpoints in main app
|
||||||
|
- Sync any data collected in collector to main app
|
||||||
|
|
||||||
|
3. **Post-incident:**
|
||||||
|
- Analyze root cause
|
||||||
|
- Fix issues
|
||||||
|
- Re-plan rollout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 13: Cost Analysis
|
||||||
|
|
||||||
|
### 13.1 Infrastructure Costs
|
||||||
|
|
||||||
|
**Collector App (estimated monthly costs with shared database):**
|
||||||
|
- Load balancer: $20/month
|
||||||
|
- App instances (2x): $50/month each = $100/month
|
||||||
|
- ~~Database: $0 (using main app's database)~~
|
||||||
|
- Redis: $30/month
|
||||||
|
- Queue workers (2x): $30/month each = $60/month
|
||||||
|
- **Total: ~$210/month**
|
||||||
|
|
||||||
|
**Savings from reduced main app load:**
|
||||||
|
- Potential downgrade of main app instance: -$30/month
|
||||||
|
|
||||||
|
**Net increase: ~$180/month**
|
||||||
|
|
||||||
|
**Database costs:**
|
||||||
|
- No additional database server needed
|
||||||
|
- May need to upgrade main database instance slightly (+$10-20/month) to handle additional connections from collector app
|
||||||
|
- **Actual net increase: ~$190-200/month**
|
||||||
|
|
||||||
|
### 13.2 ROI Analysis
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- **Reliability:** Fewer dashboard outages during traffic spikes
|
||||||
|
- **Scalability:** Handle 10x traffic without main app changes
|
||||||
|
- **Development velocity:** Deploy collector changes independently
|
||||||
|
- **Security:** Better isolation of public endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Configuration Files
|
||||||
|
|
||||||
|
### Main App `.env` (no changes needed)
|
||||||
|
|
||||||
|
Since both apps share the same database, **no configuration changes** are needed in the main app's `.env` file.
|
||||||
|
|
||||||
|
The main app continues to work exactly as before.
|
||||||
|
|
||||||
|
### Collector App `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME="Analytics Collector"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# SHARED DATABASE - Same as main app
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=main-db.local # Same database server as main app
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=analytics # Same database name as main app
|
||||||
|
DB_USERNAME=analytics_user
|
||||||
|
DB_PASSWORD=same_password_as_main_app
|
||||||
|
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
REDIS_HOST=collector-redis.local
|
||||||
|
|
||||||
|
COLLECTOR_RATE_LIMIT=120
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: nginx Configuration
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name track.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name track.yourdomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
|
||||||
|
root /var/www/analytics-collector/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=collector:10m rate=120r/m;
|
||||||
|
limit_req zone=collector burst=20 nodelay;
|
||||||
|
|
||||||
|
# Static assets (tracking script)
|
||||||
|
location ~* \.(js|gif)$ {
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C: Supervisor Configuration (Queue Workers)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:analytics-collector-worker]
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
|
command=php /var/www/analytics-collector/artisan queue:work redis --queue=tracking --sleep=1 --tries=3 --max-jobs=1000 --max-time=3600
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
user=www-data
|
||||||
|
numprocs=4
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/www/analytics-collector/storage/logs/worker.log
|
||||||
|
stopwaitsecs=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline Estimate
|
||||||
|
|
||||||
|
| Phase | Duration | Dependencies | Notes |
|
||||||
|
|-------|----------|--------------|-------|
|
||||||
|
| 1. Setup new app | 1 day | - | Laravel install + dependencies |
|
||||||
|
| 2. Database setup | 0.5 days | Phase 1 | Just configure `.env` - no migrations! |
|
||||||
|
| 3. Code extraction | 2-3 days | Phase 2 | Copy controllers, models, jobs, services |
|
||||||
|
| 4. Routes configuration | 1 day | Phase 3 | Set up API routes |
|
||||||
|
| 5. ~~Sync mechanism~~ | ~~Skipped~~ | - | **Not needed with shared DB!** |
|
||||||
|
| 6. ~~Data flow~~ | ~~Skipped~~ | - | **Automatic with shared DB!** |
|
||||||
|
| 7. Deployment prep | 2 days | Phase 4 | Infrastructure setup |
|
||||||
|
| 8. Monitoring setup | 1 day | Phase 7 | Metrics & alerting |
|
||||||
|
| 9. Optimizations | 2 days | Phase 7 | Caching, queue tuning |
|
||||||
|
| 10. Security review | 1 day | All above | CORS, rate limiting, validation |
|
||||||
|
| 11. Testing | 2-3 days | Phase 10 | Unit + integration + load tests |
|
||||||
|
| 12. Staged rollout | 1 week | Phase 11 | Gradual traffic migration |
|
||||||
|
|
||||||
|
**Total estimated time: 2-3 weeks** (reduced from 3-4 weeks due to no sync mechanism needed!)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Post-Launch (30 days)
|
||||||
|
|
||||||
|
- **Uptime:** > 99.9% for collector app
|
||||||
|
- **Response time:** p95 < 100ms for collection endpoints
|
||||||
|
- **Error rate:** < 0.5%
|
||||||
|
- **Data accuracy:** 100% match vs. pre-migration baseline
|
||||||
|
- **Cost:** Within 10% of projections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Extracting the data collection endpoints into a separate collector app provides significant benefits in terms of scalability, reliability, and maintainability. With the **shared database approach**, implementation is significantly simplified:
|
||||||
|
|
||||||
|
### Key Benefits of Shared Database Architecture
|
||||||
|
|
||||||
|
1. **Simplicity:** No sync mechanism needed - data is immediately available to both apps
|
||||||
|
2. **Real-time:** Zero latency between data collection and analytics availability
|
||||||
|
3. **Reliability:** No sync failures or delays to handle
|
||||||
|
4. **Cost-effective:** Save ~$50/month vs. separate database approach
|
||||||
|
5. **Faster implementation:** 2-3 weeks instead of 3-4 weeks
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
|
||||||
|
1. **Database strategy:** ✅ Shared database (both apps connect to same DB)
|
||||||
|
2. **Sync mechanism:** ✅ Not needed - data is instantly shared
|
||||||
|
3. **Deployment:** Gradual rollout with feature flags
|
||||||
|
4. **Monitoring:** Comprehensive metrics and alerting from day 1
|
||||||
|
|
||||||
|
### Trade-offs to Consider
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Simplest architecture
|
||||||
|
- No data sync complexity
|
||||||
|
- Real-time data availability
|
||||||
|
- Lower infrastructure costs
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Database is shared point of failure (mitigate with replication)
|
||||||
|
- Both apps depend on same database uptime
|
||||||
|
- Database connection pool must accommodate both apps
|
||||||
|
- Less flexibility for future independent scaling of databases
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
The **shared database approach is recommended** for your use case because:
|
||||||
|
- Your traffic volume doesn't require separate databases yet
|
||||||
|
- The simplicity significantly reduces complexity and development time
|
||||||
|
- You can always migrate to separate databases later if needed
|
||||||
|
- The cost savings (~$50/month) and time savings (1-2 weeks) are significant
|
||||||
|
|
||||||
|
This plan provides a clear roadmap for the extraction while minimizing risk and maintaining data integrity throughout the migration process.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
|
'allowed_origins' => ['*'],
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => ['*'],
|
||||||
|
|
||||||
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 86400,
|
||||||
|
|
||||||
|
'supports_credentials' => true,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -25,8 +25,12 @@ return new class extends Migration
|
|||||||
$table->index(['website_id', 'viewed_at']);
|
$table->index(['website_id', 'viewed_at']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Index only the first 255 characters of path to avoid key length issues
|
// SQLite-compatible path index (MySQL would use path(255) prefix index)
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
DB::statement('ALTER TABLE page_views ADD INDEX page_views_path_index (path(255))');
|
DB::statement('ALTER TABLE page_views ADD INDEX page_views_path_index (path(255))');
|
||||||
|
} else {
|
||||||
|
DB::statement('CREATE INDEX page_views_path_index ON page_views (path)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ return new class extends Migration
|
|||||||
$table->index(['website_id', 'date']);
|
$table->index(['website_id', 'date']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create unique constraint with prefix length on path to avoid key length issues
|
// Create unique constraint (SQLite-compatible)
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
DB::statement('ALTER TABLE page_daily_aggregates ADD UNIQUE page_daily_aggregates_website_id_date_path_unique (website_id, date, path(255))');
|
DB::statement('ALTER TABLE page_daily_aggregates ADD UNIQUE page_daily_aggregates_website_id_date_path_unique (website_id, date, path(255))');
|
||||||
|
} else {
|
||||||
|
DB::statement('CREATE UNIQUE INDEX page_daily_aggregates_website_id_date_path_unique ON page_daily_aggregates (website_id, date, path)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
|
|||||||
@@ -19,9 +19,14 @@ return new class extends Migration
|
|||||||
$table->index(['website_id', 'is_bounce', 'started_at'], 'sess_bounce_lookup_idx');
|
$table->index(['website_id', 'is_bounce', 'started_at'], 'sess_bounce_lookup_idx');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add indexes with prefix length on TEXT columns to avoid key length issues
|
// Add indexes on text columns (SQLite-compatible)
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
DB::statement('ALTER TABLE analytics_sessions ADD INDEX sess_entry_page_idx (website_id, entry_page(255))');
|
DB::statement('ALTER TABLE analytics_sessions ADD INDEX sess_entry_page_idx (website_id, entry_page(255))');
|
||||||
DB::statement('ALTER TABLE analytics_sessions ADD INDEX sess_exit_page_idx (website_id, exit_page(255))');
|
DB::statement('ALTER TABLE analytics_sessions ADD INDEX sess_exit_page_idx (website_id, exit_page(255))');
|
||||||
|
} else {
|
||||||
|
DB::statement('CREATE INDEX sess_entry_page_idx ON analytics_sessions (website_id, entry_page)');
|
||||||
|
DB::statement('CREATE INDEX sess_exit_page_idx ON analytics_sessions (website_id, exit_page)');
|
||||||
|
}
|
||||||
|
|
||||||
Schema::table('events', function (Blueprint $table) {
|
Schema::table('events', function (Blueprint $table) {
|
||||||
$table->index('visitor_id', 'ev_visitor_id_idx');
|
$table->index('visitor_id', 'ev_visitor_id_idx');
|
||||||
@@ -44,8 +49,13 @@ return new class extends Migration
|
|||||||
$table->dropIndex('sess_bounce_lookup_idx');
|
$table->dropIndex('sess_bounce_lookup_idx');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
DB::statement('ALTER TABLE analytics_sessions DROP INDEX sess_entry_page_idx');
|
DB::statement('ALTER TABLE analytics_sessions DROP INDEX sess_entry_page_idx');
|
||||||
DB::statement('ALTER TABLE analytics_sessions DROP INDEX sess_exit_page_idx');
|
DB::statement('ALTER TABLE analytics_sessions DROP INDEX sess_exit_page_idx');
|
||||||
|
} else {
|
||||||
|
DB::statement('DROP INDEX IF EXISTS sess_entry_page_idx');
|
||||||
|
DB::statement('DROP INDEX IF EXISTS sess_exit_page_idx');
|
||||||
|
}
|
||||||
|
|
||||||
Schema::table('events', function (Blueprint $table) {
|
Schema::table('events', function (Blueprint $table) {
|
||||||
$table->dropIndex('ev_visitor_id_idx');
|
$table->dropIndex('ev_visitor_id_idx');
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('websites', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->nullable()->after('domain');
|
||||||
|
$table->unique('slug');
|
||||||
|
$table->index('slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate slugs for existing websites
|
||||||
|
$websites = DB::table('websites')->get();
|
||||||
|
foreach ($websites as $website) {
|
||||||
|
$baseSlug = Str::slug($website->name);
|
||||||
|
$slug = $baseSlug;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
// Ensure uniqueness
|
||||||
|
while (DB::table('websites')->where('slug', $slug)->where('id', '!=', $website->id)->exists()) {
|
||||||
|
$slug = $baseSlug . '-' . $counter;
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('websites')->where('id', $website->id)->update(['slug' => $slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make slug non-nullable after populating
|
||||||
|
Schema::table('websites', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('websites', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['slug']);
|
||||||
|
$table->dropIndex(['slug']);
|
||||||
|
$table->dropColumn('slug');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+2
-1
@@ -23,7 +23,8 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_DATABASE" value="testing"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value=""/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
|||||||
@@ -33,10 +33,11 @@
|
|||||||
>
|
>
|
||||||
{{-- Logo + Website --}}
|
{{-- Logo + Website --}}
|
||||||
<div class="flex items-center gap-3 px-5 py-5 border-b border-gray-800">
|
<div class="flex items-center gap-3 px-5 py-5 border-b border-gray-800">
|
||||||
<div class="flex-shrink-0 w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
<div class="flex-shrink-0 w-8 h-8 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<img src="{{ asset('/assets/img/logo_top_small.png') }}" alt="LibreMetrics">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
{{-- <svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">--}}
|
||||||
</svg>
|
{{-- <path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />--}}
|
||||||
|
{{-- </svg>--}}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<a href="{{ route('websites.index') }}" class="text-sm font-semibold text-white truncate block hover:text-indigo-300 transition">{{ config('app.name', 'Analytics') }}</a>
|
<a href="{{ route('websites.index') }}" class="text-sm font-semibold text-white truncate block hover:text-indigo-300 transition">{{ config('app.name', 'Analytics') }}</a>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
|
{{--<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>--}}
|
||||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
|
{{-- <path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>--}}
|
||||||
</svg>
|
{{--</svg>--}}
|
||||||
|
<img src="{{ asset('/assets/img/auth_logo_icon_main.png') }}" alt="LibreMetrics">
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -182,7 +182,17 @@
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: true,
|
||||||
cutout: '65%',
|
cutout: '65%',
|
||||||
plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 } },
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 8,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.label}: ${ctx.parsed.toLocaleString()} sessions`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<!-- Analytics Tracker -->
|
||||||
|
<script defer src="http://www.libremetrics.com/api/cdn/resources/TA-ELDLQF5UOA.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<!-- Analytics Tracker -->
|
||||||
|
<script defer src="http://www.libremetrics.com/api/cdn/resources/TA-ELDLQF5UOA.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans text-gray-900 antialiased">
|
<body class="font-sans text-gray-900 antialiased">
|
||||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
|
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
<div class="flex justify-between h-16">
|
<div class="flex justify-between h-16">
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<a href="{{ route('websites.index') }}" class="flex items-center gap-2.5">
|
<a href="{{ route('websites.index') }}" class="flex items-center gap-2.5">
|
||||||
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
<div class="w-8 h-8 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<img src="{{ asset('/assets/img/logo_top_small.png') }}" alt="LibreMetrics">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
{{-- <svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">--}}
|
||||||
</svg>
|
{{-- <path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />--}}
|
||||||
|
{{-- </svg>--}}
|
||||||
</div>
|
</div>
|
||||||
<span class="font-semibold text-gray-900">{{ config('app.name', 'Analytics') }}</span>
|
<span class="font-semibold text-gray-900">{{ config('app.name', 'Analytics') }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -76,8 +76,9 @@
|
|||||||
cornerRadius: 8,
|
cornerRadius: 8,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx) => {
|
label: (ctx) => {
|
||||||
|
const countryName = ctx.raw?.feature?.properties?.name || 'Unknown';
|
||||||
const val = ctx.raw?.value || 0;
|
const val = ctx.raw?.value || 0;
|
||||||
return val > 0 ? `${ctx.label}: ${val.toLocaleString()} visitors` : `${ctx.label}: 0`;
|
return val > 0 ? `${countryName}: ${val.toLocaleString()} visitors` : `${countryName}: 0 visitors`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Send Beacon ----
|
// ---- Send Data ----
|
||||||
|
|
||||||
function send(data) {
|
function send(data) {
|
||||||
var vid = getVisitorId();
|
var vid = getVisitorId();
|
||||||
@@ -156,15 +156,13 @@
|
|||||||
os_version: os.version.substring(0, 20),
|
os_version: os.version.substring(0, 20),
|
||||||
}, utm, data);
|
}, utm, data);
|
||||||
|
|
||||||
var blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
|
try {
|
||||||
|
|
||||||
if (navigator.sendBeacon) {
|
|
||||||
navigator.sendBeacon(COLLECT_URL, blob);
|
|
||||||
} else {
|
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", COLLECT_URL, true);
|
xhr.open("POST", COLLECT_URL, true);
|
||||||
xhr.setRequestHeader("Content-Type", "application/json");
|
xhr.setRequestHeader("Content-Type", "application/json");
|
||||||
xhr.send(JSON.stringify(payload));
|
xhr.send(JSON.stringify(payload));
|
||||||
|
} catch(e) {
|
||||||
|
// Silent fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<x-input-label for="domain" :value="__('Domain')" />
|
<x-input-label for="domain" :value="__('Domain')" />
|
||||||
<x-text-input id="domain" name="domain" type="text" class="mt-1 block w-full" :value="old('domain')" required placeholder="example.com" />
|
<x-text-input id="domain" name="domain" type="text" class="mt-1 block w-full" :value="old('domain')" required placeholder="example.com" />
|
||||||
<p class="mt-1 text-sm text-gray-500">{{ __('Enter the domain without http:// or https://') }}</p>
|
<p class="mt-1 text-sm text-gray-500">{{ __('Enter your domain in any format (e.g., example.com, www.example.com, https://example.com)') }}</p>
|
||||||
<x-input-error class="mt-2" :messages="$errors->get('domain')" />
|
<x-input-error class="mt-2" :messages="$errors->get('domain')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<x-input-label for="domain" :value="__('Domain')" />
|
<x-input-label for="domain" :value="__('Domain')" />
|
||||||
<x-text-input id="domain" name="domain" type="text" class="mt-1 block w-full" :value="old('domain', $website->domain)" required />
|
<x-text-input id="domain" name="domain" type="text" class="mt-1 block w-full" :value="old('domain', $website->domain)" required />
|
||||||
<p class="mt-1 text-sm text-gray-500">{{ __('Enter the domain without http:// or https://') }}</p>
|
<p class="mt-1 text-sm text-gray-500">{{ __('Enter your domain in any format (e.g., example.com, www.example.com, https://example.com)') }}</p>
|
||||||
<x-input-error class="mt-2" :messages="$errors->get('domain')" />
|
<x-input-error class="mt-2" :messages="$errors->get('domain')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,16 @@
|
|||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ __('Install the tracking code') }}</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ __('Install the tracking code') }}</h3>
|
||||||
<p class="text-sm text-gray-600 mb-6">
|
<p class="text-sm text-gray-600 mb-2">
|
||||||
{{ __('Copy and paste the following code snippet into the') }}
|
{{ __('Copy and paste the following code snippet into the') }}
|
||||||
<code class="text-sm bg-gray-100 px-1.5 py-0.5 rounded"><head></code>
|
<code class="text-sm bg-gray-100 px-1.5 py-0.5 rounded"><head></code>
|
||||||
{{ __('section of every page on') }}
|
{{ __('section of every page on') }}
|
||||||
<strong>{{ $website->domain }}</strong>.
|
<strong>{{ $website->domain }}</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2 mb-6">
|
||||||
|
<strong>{{ __('Ad-blocker resistant:') }}</strong>
|
||||||
|
{{ __('This snippet uses paths that bypass most ad blockers.') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="relative" x-data="{ copied: false }">
|
<div class="relative" x-data="{ copied: false }">
|
||||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm overflow-x-auto"><code id="snippet-code">{{ $website->embedSnippet() }}</code></pre>
|
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm overflow-x-auto"><code id="snippet-code">{{ $website->embedSnippet() }}</code></pre>
|
||||||
@@ -79,11 +83,11 @@ ta('event', 'purchase', {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">{{ __('Script URL') }}</dt>
|
<dt class="text-sm font-medium text-gray-500">{{ __('Script URL') }}</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all">{{ route('tracking.script', $website->tracking_id) }}</dd>
|
<dd class="mt-1 text-sm text-gray-900 font-mono break-all">{{ route('tracking.script.alt', $website->tracking_id) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">{{ __('Collection Endpoint') }}</dt>
|
<dt class="text-sm font-medium text-gray-500">{{ __('Collection Endpoint') }}</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all">{{ route('collect.store') }}</dd>
|
<dd class="mt-1 text-sm text-gray-900 font-mono break-all">{{ route('collect.store.alt') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -268,7 +268,7 @@
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-lg font-semibold text-white mb-2">{{ config('app.name') }}</p>
|
<p class="text-lg font-semibold text-white mb-2">{{ config('app.name') }}</p>
|
||||||
<p class="text-sm">Privacy-first analytics. Made and hosted in the EU.</p>
|
<p class="text-sm">Privacy-first analytics. Made and <a href="https://clients.hostslim.eu/aff.php?aff=300" target="_blank">hosted</a> in the EU.</p>
|
||||||
<p class="text-sm mt-4">© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>
|
<p class="text-sm mt-4">© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ use App\Http\Controllers\CollectController;
|
|||||||
use App\Http\Controllers\TrackingScriptController;
|
use App\Http\Controllers\TrackingScriptController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Legacy /collect endpoint alias (used in tests and older integrations)
|
||||||
|
Route::post('/collect', [CollectController::class, 'store'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.legacy');
|
||||||
|
|
||||||
|
Route::get('/collect/pixel/{trackingId}', [CollectController::class, 'pixel'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.pixel.legacy');
|
||||||
|
|
||||||
|
Route::get('/pixel', [CollectController::class, 'pixel'])
|
||||||
|
->middleware(['collect.cors', 'throttle:collect'])
|
||||||
|
->name('collect.pixel.query');
|
||||||
|
|
||||||
|
// Original endpoints (kept for backward compatibility)
|
||||||
Route::get('/t/{trackingId}.js', [TrackingScriptController::class, 'script'])
|
Route::get('/t/{trackingId}.js', [TrackingScriptController::class, 'script'])
|
||||||
->middleware('collect.cors')
|
->middleware('collect.cors')
|
||||||
->name('tracking.script');
|
->name('tracking.script');
|
||||||
@@ -19,6 +33,21 @@ Route::middleware('collect.cors')->group(function () {
|
|||||||
->name('collect.pixel');
|
->name('collect.pixel');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ad-blocker resistant endpoints (use these in production)
|
||||||
|
Route::get('/cdn/resources/{trackingId}.js', [TrackingScriptController::class, 'script'])
|
||||||
|
->middleware('collect.cors')
|
||||||
|
->name('tracking.script.alt');
|
||||||
|
|
||||||
|
Route::middleware('collect.cors')->group(function () {
|
||||||
|
Route::post('/cdn/feedback', [CollectController::class, 'store'])
|
||||||
|
->middleware('throttle:collect')
|
||||||
|
->name('collect.store.alt');
|
||||||
|
|
||||||
|
Route::get('/cdn/status.gif', [CollectController::class, 'pixel'])
|
||||||
|
->middleware('throttle:collect')
|
||||||
|
->name('collect.pixel.alt');
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->name('api.v1.')->group(function () {
|
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->name('api.v1.')->group(function () {
|
||||||
Route::get('websites', [ApiWebsiteController::class, 'index'])->name('websites.index');
|
Route::get('websites', [ApiWebsiteController::class, 'index'])->name('websites.index');
|
||||||
Route::get('websites/{website}/stats', [ApiWebsiteController::class, 'stats'])->name('websites.stats');
|
Route::get('websites/{website}/stats', [ApiWebsiteController::class, 'stats'])->name('websites.stats');
|
||||||
|
|||||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class PasswordConfirmationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_confirm_password_screen_can_be_rendered(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get('/confirm-password');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_password_can_be_confirmed(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
|
||||||
'password' => 'password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertRedirect();
|
|
||||||
$response->assertSessionHasNoErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_password_is_not_confirmed_with_invalid_password(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
|
||||||
'password' => 'wrong-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertSessionHasErrors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@ class WebsiteControllerTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
$this->withoutVite();
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -6,5 +6,9 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
|||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
//
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutVite();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user