Skip to content

Commit 9360345

Browse files
authored
Merge pull request #55 from caendesilva/simple-analytics-feature
[3.x] Simple analytics feature
2 parents dc35cf4 + 0511795 commit 9360345

File tree

12 files changed

+519
-24
lines changed

12 files changed

+519
-24
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,7 @@ BLOGKIT_EASYMDE_TOOLBAR=NULL
6363
BLOGKIT_BANS_ENABLED=true
6464
BLOGKIT_DEMO_MODE=false #Do not use in production!
6565
BLOGKIT_TAGS_ENABLED=true
66-
BLOGKIT_TAGS_ENABLED_ON_CARDS=true
66+
BLOGKIT_TAGS_ENABLED_ON_CARDS=true
67+
68+
ANALYTICS_ENABLED=true
69+
ANALYTICS_SALT=random-secret-string

app/Http/Controllers/DashboardController.php

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Models\Post;
66
use App\Models\User;
77
use App\Models\Comment;
8+
use App\Models\PageView;
89
use Illuminate\Http\Request;
910
use Illuminate\Support\Facades\Gate;
1011

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

28+
$data = [];
29+
2730
// If the user is an admin they can manage all posts and users
2831
if ($request->user()->is_admin) {
29-
$posts = Post::all();
30-
31-
$users = User::all();
32+
$data['posts'] = Post::all();
33+
$data['users'] = User::all();
3234

3335
// If comments are enabled or if there are comments we load them
3436
if (config('blog.allowComments') || Comment::count()) {
35-
$comments = Comment::all();
37+
$data['comments'] = Comment::all();
3638
}
37-
}
3839

40+
// Add analytics data if enabled
41+
if (config('analytics.enabled')) {
42+
$pageViews = PageView::all();
43+
44+
// Get traffic data for the last 30 days
45+
$thirtyDaysAgo = now()->subDays(30);
46+
$trafficData = PageView::where('created_at', '>=', $thirtyDaysAgo)
47+
->get()
48+
->groupBy(function ($view) {
49+
return $view->created_at->format('Y-m-d');
50+
});
51+
52+
$data['analytics'] = [
53+
'total_views' => $pageViews->count(),
54+
'unique_visitors' => $pageViews->groupBy('anonymous_id')->count(),
55+
'popular_pages' => PageView::select('page')
56+
->selectRaw('COUNT(*) as views')
57+
->selectRaw('COUNT(DISTINCT anonymous_id) as visitors')
58+
->groupBy('page')
59+
->orderByDesc('views')
60+
->limit(10)
61+
->get(),
62+
'top_referrers' => PageView::whereNotNull('referrer')
63+
->where('referrer', 'not like', '?ref=%')
64+
->select('referrer')
65+
->selectRaw('COUNT(*) as views')
66+
->selectRaw('COUNT(DISTINCT anonymous_id) as visitors')
67+
->groupBy('referrer')
68+
->orderByDesc('views')
69+
->limit(10)
70+
->get(),
71+
'top_refs' => PageView::where('referrer', 'like', '?ref=%')
72+
->select('referrer')
73+
->selectRaw('COUNT(*) as views')
74+
->selectRaw('COUNT(DISTINCT anonymous_id) as visitors')
75+
->groupBy('referrer')
76+
->orderByDesc('views')
77+
->limit(10)
78+
->get(),
79+
'traffic_data' => [
80+
'dates' => $trafficData->keys(),
81+
'views' => $trafficData->map->count(),
82+
'unique' => $trafficData->map(fn ($views) => $views->groupBy('anonymous_id')->count()),
83+
],
84+
];
85+
}
86+
}
3987
// Otherwise if the user is an author we show their posts
4088
elseif ($request->user()->is_author) {
41-
$posts = $request->user()->posts;
89+
$data['posts'] = $request->user()->posts;
4290
}
4391

4492
// Return the view with the data we prepared
45-
return view('dashboard', [
46-
'posts' => $posts ?? false,
47-
'users' => $users ?? false,
48-
'comments' => $comments ?? false,
49-
]);
93+
return view('dashboard', $data);
5094
}
5195
}

app/Http/Kernel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class Kernel extends HttpKernel
3939
\Illuminate\Routing\Middleware\SubstituteBindings::class,
4040

4141
\App\Http\Middleware\EnsureUserIsNotBanned::class,
42+
\App\Http\Middleware\AnalyticsMiddleware::class,
4243
],
4344

4445
'api' => [
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use App\Models\PageView;
8+
use Illuminate\Support\Facades\Cache;
9+
use Illuminate\Support\Facades\Config;
10+
11+
class AnalyticsMiddleware
12+
{
13+
/**
14+
* Handle an incoming request.
15+
*
16+
* @param \Illuminate\Http\Request $request
17+
* @param \Closure $next
18+
* @return mixed
19+
*/
20+
public function handle(Request $request, Closure $next)
21+
{
22+
$response = $next($request);
23+
24+
if (! Config::get('analytics.enabled')) {
25+
return $response;
26+
}
27+
28+
// Use the terminate method to execute code after the response is sent.
29+
app()->terminating(function () use ($request) {
30+
$path = $request->path();
31+
$excludedPaths = Config::get('analytics.excluded_paths', []);
32+
33+
// Check if the current path matches any excluded paths
34+
foreach ($excludedPaths as $excludedPath) {
35+
if (str_is($excludedPath, $path)) {
36+
return;
37+
}
38+
}
39+
40+
PageView::fromRequest($request);
41+
});
42+
43+
return $response;
44+
}
45+
}

app/Models/PageView.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use App\Concerns\AnalyticsDateFormatting;
6+
use App\Concerns\AnonymizesRequests;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Str;
10+
use Carbon\Carbon;
11+
12+
/**
13+
* @property string $page URL of the page visited
14+
* @property ?string $referrer URL of the page that referred the user
15+
* @property ?string $user_agent User agent string of the visitor (only stored for bots)
16+
* @property string $anonymous_id Ephemeral anonymized visitor identifier that cannot be tied to a user
17+
*/
18+
class PageView extends Model
19+
{
20+
public const UPDATED_AT = null;
21+
22+
protected $fillable = [
23+
'page',
24+
'referrer',
25+
'user_agent',
26+
'anonymous_id',
27+
];
28+
29+
protected static function boot(): void
30+
{
31+
parent::boot();
32+
33+
static::creating(function (self $model): void {
34+
// Normalize the page URL to use the path only
35+
$model->page = (parse_url($model->page, PHP_URL_PATH) ?? '/');
36+
37+
// We only store the domain of the referrer
38+
if ($model->referrer) {
39+
if (! str_starts_with($model->referrer, '?ref=')) {
40+
// We only store the domain of the referrer
41+
$model->referrer = static::normalizeDomain($model->referrer);
42+
} else {
43+
$domain = Str::after($model->referrer, '?ref=');
44+
$domain = static::normalizeDomain($domain);
45+
46+
$model->referrer = "?ref=$domain";
47+
}
48+
} else {
49+
$model->referrer = null;
50+
}
51+
52+
// We don't store user agents for non-bot users
53+
$crawlerKeywords = ['bot', 'crawl', 'spider', 'slurp', 'search', 'yahoo', 'facebook'];
54+
55+
if (! Str::contains($model->user_agent, $crawlerKeywords, true)) {
56+
$model->user_agent = null;
57+
}
58+
});
59+
}
60+
61+
public static function fromRequest(Request $request): static
62+
{
63+
// Is a ref query parameter present? If so, we'll store it as a referrer
64+
$ref = $request->query('ref');
65+
if ($ref) {
66+
$ref = '?ref='.$ref;
67+
}
68+
69+
return static::create([
70+
'page' => $request->url(),
71+
'referrer' => $ref ?? $request->header('referer') ?? $request->header('referrer'),
72+
'user_agent' => $request->userAgent(),
73+
'anonymous_id' => self::anonymizeRequest($request),
74+
]);
75+
}
76+
77+
public function getCreatedAtAttribute(string $date): Carbon
78+
{
79+
// Include the timezone when casting the date to a string
80+
return Carbon::parse($date)->settings(['toStringFormat' => 'Y-m-d H:i:s T']);
81+
}
82+
83+
protected static function normalizeDomain(string $url): string
84+
{
85+
if (! Str::startsWith($url, 'http')) {
86+
$url = 'https://'.$url;
87+
}
88+
89+
return Str::after(parse_url($url, PHP_URL_HOST), 'www.');
90+
}
91+
92+
protected static function anonymizeRequest(Request $request): string
93+
{
94+
// As we are not interested in tracking users, we generate an ephemeral hash
95+
// based on the IP, user agent, and a salt to track unique visits per day.
96+
// This system is designed so that a visitor cannot be tracked across days, nor can it be tied to a specific person.
97+
// Due to the salting with a secret environment value, it can't be reverse engineered by creating rainbow tables.
98+
// The current date is also included in the hash in order to make them unique per day.
99+
100+
// 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.
101+
102+
$forwardIp = $request->header('X-Forwarded-For');
103+
104+
if ($forwardIp !== null) {
105+
// 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.
106+
107+
$ip = Str::before($forwardIp, ',');
108+
} else {
109+
$ip = $request->ip();
110+
}
111+
112+
return substr(hash('sha256', $ip.$request->userAgent().config('hashing.anonymizer_salt').now()->format('Y-m-d')), 0, 40);
113+
}
114+
}

app/Models/Post.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,38 @@ public function comments(): \Illuminate\Database\Eloquent\Relations\HasMany
141141
{
142142
return $this->hasMany(Comment::class, 'post_id', 'id');
143143
}
144+
145+
/**
146+
* Get the view count for the post
147+
*/
148+
public function getViewCount(): int
149+
{
150+
if (! config('analytics.enabled')) {
151+
throw new \BadMethodCallException('Analytics are not enabled');
152+
}
153+
154+
$cacheKey = "post.{$this->id}.views";
155+
$cacheDuration = config('analytics.view_count_cache_duration');
156+
157+
// Get the cached value (even if expired)
158+
$value = cache()->get($cacheKey);
159+
160+
if ($value !== null) {
161+
// If the cache exists but is stale, dispatch background refresh
162+
if (! cache()->has($cacheKey)) {
163+
dispatch(function () use ($cacheKey, $cacheDuration) {
164+
$newValue = PageView::where('page', route('posts.show', $this, false))->count();
165+
cache()->put($cacheKey, $newValue, now()->addMinutes($cacheDuration));
166+
})->afterResponse();
167+
}
168+
169+
return $value;
170+
}
171+
172+
// If no cached value exists at all, fetch and cache synchronously
173+
$value = PageView::where('page', route('posts.show', $this, false))->count();
174+
cache()->put($cacheKey, $value, now()->addMinutes($cacheDuration));
175+
176+
return $value;
177+
}
144178
}

config/analytics.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
return [
4+
5+
/*
6+
|--------------------------------------------------------------------------
7+
| Analytics Enabled
8+
|--------------------------------------------------------------------------
9+
|
10+
| This option controls whether the analytics feature is enabled.
11+
| You can disable analytics entirely by setting this to false.
12+
|
13+
*/
14+
'enabled' => env('ANALYTICS_ENABLED', true),
15+
16+
/*
17+
|--------------------------------------------------------------------------
18+
| Anonymization Salt
19+
|--------------------------------------------------------------------------
20+
|
21+
| This salt is used to anonymize visitor identifiers. It should be a unique
22+
| and secret string that ensures identifiers cannot be tracked across
23+
| other platforms, or by generating rainbow tables.
24+
|
25+
*/
26+
'anonymization_salt' => env('ANALYTICS_SALT', null),
27+
28+
/*
29+
|--------------------------------------------------------------------------
30+
| View Count Cache Duration
31+
|--------------------------------------------------------------------------
32+
|
33+
| The duration in minutes to cache post view counts. This helps reduce
34+
| database load while keeping view counts reasonably up to date.
35+
|
36+
*/
37+
'view_count_cache_duration' => 3600, // 1 hour
38+
39+
/*
40+
|--------------------------------------------------------------------------
41+
| Excluded Paths
42+
|--------------------------------------------------------------------------
43+
|
44+
| List of paths that should be excluded from analytics tracking.
45+
| Supports wildcards using * (e.g. 'api/*', 'admin/*').
46+
|
47+
*/
48+
'excluded_paths' => [
49+
// Examples:
50+
// 'api/*',
51+
// 'admin/*',
52+
// 'health-check',
53+
],
54+
55+
];
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::create('page_views', function (Blueprint $table) {
15+
$table->id();
16+
$table->string('page');
17+
$table->string('referrer')->nullable();
18+
$table->string('user_agent')->nullable(); // Only added when the user is a bot/crawler
19+
$table->string('anonymous_id', 40); // Ephemeral anonymized identifier for the user to track daily unique visits
20+
$table->timestamp('created_at')->nullable();
21+
});
22+
}
23+
24+
/**
25+
* Reverse the migrations.
26+
*/
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('page_views');
30+
}
31+
};

public/css/app.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)