-
-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #55 from caendesilva/simple-analytics-feature
[3.x] Simple analytics feature
- Loading branch information
Showing
12 changed files
with
519 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
31
database/migrations/2024_04_04_160545_create_page_views_table.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
}; |
Large diffs are not rendered by default.
Oops, something went wrong.
Oops, something went wrong.