Skip to content

Commit

Permalink
Merge pull request #55 from caendesilva/simple-analytics-feature
Browse files Browse the repository at this point in the history
[3.x] Simple analytics feature
  • Loading branch information
caendesilva authored Nov 30, 2024
2 parents dc35cf4 + 0511795 commit 9360345
Show file tree
Hide file tree
Showing 12 changed files with 519 additions and 24 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,7 @@ BLOGKIT_EASYMDE_TOOLBAR=NULL
BLOGKIT_BANS_ENABLED=true
BLOGKIT_DEMO_MODE=false #Do not use in production!
BLOGKIT_TAGS_ENABLED=true
BLOGKIT_TAGS_ENABLED_ON_CARDS=true
BLOGKIT_TAGS_ENABLED_ON_CARDS=true

ANALYTICS_ENABLED=true
ANALYTICS_SALT=random-secret-string
66 changes: 55 additions & 11 deletions app/Http/Controllers/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Models\Post;
use App\Models\User;
use App\Models\Comment;
use App\Models\PageView;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

Expand All @@ -24,28 +25,71 @@ public function show(Request $request)
abort(403);
}

$data = [];

// If the user is an admin they can manage all posts and users
if ($request->user()->is_admin) {
$posts = Post::all();

$users = User::all();
$data['posts'] = Post::all();
$data['users'] = User::all();

// If comments are enabled or if there are comments we load them
if (config('blog.allowComments') || Comment::count()) {
$comments = Comment::all();
$data['comments'] = Comment::all();
}
}

// Add analytics data if enabled
if (config('analytics.enabled')) {
$pageViews = PageView::all();

// Get traffic data for the last 30 days
$thirtyDaysAgo = now()->subDays(30);
$trafficData = PageView::where('created_at', '>=', $thirtyDaysAgo)
->get()
->groupBy(function ($view) {
return $view->created_at->format('Y-m-d');
});

$data['analytics'] = [
'total_views' => $pageViews->count(),
'unique_visitors' => $pageViews->groupBy('anonymous_id')->count(),
'popular_pages' => PageView::select('page')
->selectRaw('COUNT(*) as views')
->selectRaw('COUNT(DISTINCT anonymous_id) as visitors')
->groupBy('page')
->orderByDesc('views')
->limit(10)
->get(),
'top_referrers' => PageView::whereNotNull('referrer')
->where('referrer', 'not like', '?ref=%')
->select('referrer')
->selectRaw('COUNT(*) as views')
->selectRaw('COUNT(DISTINCT anonymous_id) as visitors')
->groupBy('referrer')
->orderByDesc('views')
->limit(10)
->get(),
'top_refs' => PageView::where('referrer', 'like', '?ref=%')
->select('referrer')
->selectRaw('COUNT(*) as views')
->selectRaw('COUNT(DISTINCT anonymous_id) as visitors')
->groupBy('referrer')
->orderByDesc('views')
->limit(10)
->get(),
'traffic_data' => [
'dates' => $trafficData->keys(),
'views' => $trafficData->map->count(),
'unique' => $trafficData->map(fn ($views) => $views->groupBy('anonymous_id')->count()),
],
];
}
}
// Otherwise if the user is an author we show their posts
elseif ($request->user()->is_author) {
$posts = $request->user()->posts;
$data['posts'] = $request->user()->posts;
}

// Return the view with the data we prepared
return view('dashboard', [
'posts' => $posts ?? false,
'users' => $users ?? false,
'comments' => $comments ?? false,
]);
return view('dashboard', $data);
}
}
1 change: 1 addition & 0 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class Kernel extends HttpKernel
\Illuminate\Routing\Middleware\SubstituteBindings::class,

\App\Http\Middleware\EnsureUserIsNotBanned::class,
\App\Http\Middleware\AnalyticsMiddleware::class,
],

'api' => [
Expand Down
45 changes: 45 additions & 0 deletions app/Http/Middleware/AnalyticsMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\PageView;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;

class AnalyticsMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$response = $next($request);

if (! Config::get('analytics.enabled')) {
return $response;
}

// Use the terminate method to execute code after the response is sent.
app()->terminating(function () use ($request) {
$path = $request->path();
$excludedPaths = Config::get('analytics.excluded_paths', []);

// Check if the current path matches any excluded paths
foreach ($excludedPaths as $excludedPath) {
if (str_is($excludedPath, $path)) {
return;
}
}

PageView::fromRequest($request);
});

return $response;
}
}
114 changes: 114 additions & 0 deletions app/Models/PageView.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

namespace App\Models;

use App\Concerns\AnalyticsDateFormatting;
use App\Concerns\AnonymizesRequests;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Carbon\Carbon;

/**
* @property string $page URL of the page visited
* @property ?string $referrer URL of the page that referred the user
* @property ?string $user_agent User agent string of the visitor (only stored for bots)
* @property string $anonymous_id Ephemeral anonymized visitor identifier that cannot be tied to a user
*/
class PageView extends Model
{
public const UPDATED_AT = null;

protected $fillable = [
'page',
'referrer',
'user_agent',
'anonymous_id',
];

protected static function boot(): void
{
parent::boot();

static::creating(function (self $model): void {
// Normalize the page URL to use the path only
$model->page = (parse_url($model->page, PHP_URL_PATH) ?? '/');

// We only store the domain of the referrer
if ($model->referrer) {
if (! str_starts_with($model->referrer, '?ref=')) {
// We only store the domain of the referrer
$model->referrer = static::normalizeDomain($model->referrer);
} else {
$domain = Str::after($model->referrer, '?ref=');
$domain = static::normalizeDomain($domain);

$model->referrer = "?ref=$domain";
}
} else {
$model->referrer = null;
}

// We don't store user agents for non-bot users
$crawlerKeywords = ['bot', 'crawl', 'spider', 'slurp', 'search', 'yahoo', 'facebook'];

if (! Str::contains($model->user_agent, $crawlerKeywords, true)) {
$model->user_agent = null;
}
});
}

public static function fromRequest(Request $request): static
{
// Is a ref query parameter present? If so, we'll store it as a referrer
$ref = $request->query('ref');
if ($ref) {
$ref = '?ref='.$ref;
}

return static::create([
'page' => $request->url(),
'referrer' => $ref ?? $request->header('referer') ?? $request->header('referrer'),
'user_agent' => $request->userAgent(),
'anonymous_id' => self::anonymizeRequest($request),
]);
}

public function getCreatedAtAttribute(string $date): Carbon
{
// Include the timezone when casting the date to a string
return Carbon::parse($date)->settings(['toStringFormat' => 'Y-m-d H:i:s T']);
}

protected static function normalizeDomain(string $url): string
{
if (! Str::startsWith($url, 'http')) {
$url = 'https://'.$url;
}

return Str::after(parse_url($url, PHP_URL_HOST), 'www.');
}

protected static function anonymizeRequest(Request $request): string
{
// As we are not interested in tracking users, we generate an ephemeral hash
// based on the IP, user agent, and a salt to track unique visits per day.
// This system is designed so that a visitor cannot be tracked across days, nor can it be tied to a specific person.
// Due to the salting with a secret environment value, it can't be reverse engineered by creating rainbow tables.
// The current date is also included in the hash in order to make them unique per day.

// The hash is made using the SHA-256 algorithm and truncated to 40 characters to save space, as we're not too worried about collisions.

$forwardIp = $request->header('X-Forwarded-For');

if ($forwardIp !== null) {
// If the request is proxied, we use the first IP in the address list, as the actual IP belongs to the proxy which may change frequently.

$ip = Str::before($forwardIp, ',');
} else {
$ip = $request->ip();
}

return substr(hash('sha256', $ip.$request->userAgent().config('hashing.anonymizer_salt').now()->format('Y-m-d')), 0, 40);
}
}
34 changes: 34 additions & 0 deletions app/Models/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,38 @@ public function comments(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Comment::class, 'post_id', 'id');
}

/**
* Get the view count for the post
*/
public function getViewCount(): int
{
if (! config('analytics.enabled')) {
throw new \BadMethodCallException('Analytics are not enabled');
}

$cacheKey = "post.{$this->id}.views";
$cacheDuration = config('analytics.view_count_cache_duration');

// Get the cached value (even if expired)
$value = cache()->get($cacheKey);

if ($value !== null) {
// If the cache exists but is stale, dispatch background refresh
if (! cache()->has($cacheKey)) {
dispatch(function () use ($cacheKey, $cacheDuration) {
$newValue = PageView::where('page', route('posts.show', $this, false))->count();
cache()->put($cacheKey, $newValue, now()->addMinutes($cacheDuration));
})->afterResponse();
}

return $value;
}

// If no cached value exists at all, fetch and cache synchronously
$value = PageView::where('page', route('posts.show', $this, false))->count();
cache()->put($cacheKey, $value, now()->addMinutes($cacheDuration));

return $value;
}
}
55 changes: 55 additions & 0 deletions config/analytics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

return [

/*
|--------------------------------------------------------------------------
| Analytics Enabled
|--------------------------------------------------------------------------
|
| This option controls whether the analytics feature is enabled.
| You can disable analytics entirely by setting this to false.
|
*/
'enabled' => env('ANALYTICS_ENABLED', true),

/*
|--------------------------------------------------------------------------
| Anonymization Salt
|--------------------------------------------------------------------------
|
| This salt is used to anonymize visitor identifiers. It should be a unique
| and secret string that ensures identifiers cannot be tracked across
| other platforms, or by generating rainbow tables.
|
*/
'anonymization_salt' => env('ANALYTICS_SALT', null),

/*
|--------------------------------------------------------------------------
| View Count Cache Duration
|--------------------------------------------------------------------------
|
| The duration in minutes to cache post view counts. This helps reduce
| database load while keeping view counts reasonably up to date.
|
*/
'view_count_cache_duration' => 3600, // 1 hour

/*
|--------------------------------------------------------------------------
| Excluded Paths
|--------------------------------------------------------------------------
|
| List of paths that should be excluded from analytics tracking.
| Supports wildcards using * (e.g. 'api/*', 'admin/*').
|
*/
'excluded_paths' => [
// Examples:
// 'api/*',
// 'admin/*',
// 'health-check',
],

];
31 changes: 31 additions & 0 deletions database/migrations/2024_04_04_160545_create_page_views_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('page_views', function (Blueprint $table) {
$table->id();
$table->string('page');
$table->string('referrer')->nullable();
$table->string('user_agent')->nullable(); // Only added when the user is a bot/crawler
$table->string('anonymous_id', 40); // Ephemeral anonymized identifier for the user to track daily unique visits
$table->timestamp('created_at')->nullable();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('page_views');
}
};
2 changes: 1 addition & 1 deletion public/css/app.css

Large diffs are not rendered by default.

Loading

0 comments on commit 9360345

Please sign in to comment.