feat(analytics): click recording job with IP hashing, device detection, Redis counters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 00:56:52 +02:00
parent ebdcd141bb
commit 66e559173c
4 changed files with 177 additions and 6 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Domains\Analytics\Events;
use App\Domains\Analytics\Models\Click;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class ClickRecorded implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets;
public function __construct(public readonly Click $click) {}
public function broadcastOn(): Channel
{
return new Channel("workspace.{$this->click->workspace_id}.analytics");
}
public function broadcastAs(): string
{
return 'click.recorded';
}
public function broadcastWith(): array
{
return [
'link_id' => $this->click->link_id,
'country' => $this->click->country,
'device' => $this->click->device,
'clicked_at' => $this->click->clicked_at?->toISOString(),
];
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Domains\Analytics\Jobs;
use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class RecordClickJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(
public readonly int $linkId,
public readonly string $ip,
public readonly ?string $userAgent,
public readonly array $headers,
) {}
public function handle(): void
{
$link = Link::find($this->linkId);
if (! $link) {
return;
}
$ipHash = $this->hashIp($this->ip);
$device = $this->detectDevice($this->userAgent ?? '');
$click = Click::create([
'link_id' => $this->linkId,
'workspace_id' => $link->workspace_id,
'clicked_at' => now(),
'ip_hash' => $ipHash,
'device' => $device,
'os' => $this->detectOs($this->userAgent ?? ''),
'browser' => $this->detectBrowser($this->userAgent ?? ''),
'referrer_host' => $this->extractHost($this->headers['referer'][0] ?? $this->headers['Referer'][0] ?? null),
'utm_source' => $this->headers['utm_source'][0] ?? null,
'utm_medium' => $this->headers['utm_medium'][0] ?? null,
'utm_campaign' => $this->headers['utm_campaign'][0] ?? null,
'language' => substr($this->headers['accept-language'][0] ?? 'de', 0, 10),
]);
// Increment Redis counters
$date = now()->format('Y-m-d');
Redis::incr("clicks:{$this->linkId}:{$date}");
Redis::incr("clicks:{$link->workspace_id}:monthly:" . now()->format('Ym'));
// Broadcast event for live dashboard
event(new \App\Domains\Analytics\Events\ClickRecorded($click));
}
private function hashIp(string $ip): string
{
$salt = Cache::remember('ip_salt:' . today()->format('Y-m-d'), 86400, fn () => bin2hex(random_bytes(16)));
return hash('sha256', $ip . $salt);
}
private function detectDevice(string $ua): string
{
$ua = strtolower($ua);
if (str_contains($ua, 'bot') || str_contains($ua, 'crawler') || str_contains($ua, 'spider')) {
return 'bot';
}
if (preg_match('/mobile|iphone|android.*mobile|windows phone/i', $ua)) {
return 'mobile';
}
if (preg_match('/ipad|android(?!.*mobile)|tablet/i', $ua)) {
return 'tablet';
}
if (strlen($ua) === 0) {
return 'unknown';
}
return 'desktop';
}
private function detectOs(string $ua): ?string
{
if (stripos($ua, 'Windows') !== false) return 'windows';
if (stripos($ua, 'Mac OS') !== false) return 'macos';
if (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) return 'ios';
if (stripos($ua, 'Android') !== false) return 'android';
if (stripos($ua, 'Linux') !== false) return 'linux';
return null;
}
private function detectBrowser(string $ua): ?string
{
if (stripos($ua, 'Edge') !== false || stripos($ua, 'Edg/') !== false) return 'edge';
if (stripos($ua, 'Chrome') !== false) return 'chrome';
if (stripos($ua, 'Firefox') !== false) return 'firefox';
if (stripos($ua, 'Safari') !== false && stripos($ua, 'Chrome') === false) return 'safari';
return null;
}
private function extractHost(?string $url): ?string
{
if (! $url) return null;
$parsed = parse_url($url);
return $parsed['host'] ?? null;
}
}

View File

@ -42,12 +42,12 @@ class RedirectController extends Controller
}
// 4. RecordClickJob dispatched in Task 7
// \App\Domains\Analytics\Jobs\RecordClickJob::dispatch(
// (int) $data['link_id'],
// $request->ip(),
// $request->userAgent(),
// $request->headers->all()
// )->onQueue('clicks');
\App\Domains\Analytics\Jobs\RecordClickJob::dispatch(
(int) $data['link_id'],
$request->ip(),
$request->userAgent() ?? '',
$request->headers->all()
)->onQueue('clicks');
// 5. 302 redirect
return redirect($data['target'], 302);

View File

@ -0,0 +1,27 @@
<?php
use App\Domains\Analytics\Jobs\RecordClickJob;
use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link;
it('records a click to the database', function () {
$link = Link::factory()->create();
RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0)', []);
$this->assertDatabaseCount('clicks', 1);
$this->assertDatabaseHas('clicks', [
'link_id' => $link->id,
'device' => 'mobile',
]);
});
it('hashes IP address before storage', function () {
$link = Link::factory()->create();
RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'test-agent', []);
$click = Click::first();
expect($click->ip_hash)->not->toBe('1.2.3.4')
->and(strlen($click->ip_hash))->toBe(64);
});