Add device, platform, and country analytics to link show page.
Deploy Ladill Link / deploy (push) Successful in 32s

Record parsed user-agent and geo data on clicks, then surface breakdowns and richer recent-click context on per-link and account analytics views.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 19:32:40 +00:00
co-authored by Cursor
parent 9d89aef17e
commit 0e5e0f6ca5
12 changed files with 453 additions and 32 deletions
@@ -21,6 +21,10 @@ class AnalyticsController extends Controller
'topLinks' => $this->analytics->topLinksForAccount($account),
'recentClicks' => $this->analytics->recentClicksForAccount($account),
'referrers' => $this->analytics->referrersForAccount($account),
'devices' => $this->analytics->breakdownForAccount($account, 'device_type'),
'browsers' => $this->analytics->breakdownForAccount($account, 'browser'),
'platforms' => $this->analytics->breakdownForAccount($account, 'os'),
'countries' => $this->analytics->breakdownForAccount($account, 'country'),
]);
}
}
+10 -1
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\ShortLink;
use App\Services\Link\LinkAnalyticsService;
use App\Services\Link\LinkBillingService;
use App\Services\Link\LinkCatalogSyncService;
use App\Services\Link\LinkManagerService;
@@ -18,6 +19,7 @@ class LinkController extends Controller
private LinkManagerService $links,
private LinkBillingService $billing,
private LinkCatalogSyncService $catalogSync,
private LinkAnalyticsService $analytics,
) {}
public function index(Request $request): View
@@ -73,7 +75,14 @@ class LinkController extends Controller
return view('links.show', [
'link' => $link,
'recentClicks' => $link->clicks()->latest('clicked_at')->limit(20)->get(),
'summary' => $this->analytics->summaryForLink($link),
'dailyClicks' => $this->analytics->dailyClicksForLink($link, 30),
'devices' => $this->analytics->breakdownForLink($link, 'device_type'),
'browsers' => $this->analytics->breakdownForLink($link, 'browser'),
'platforms' => $this->analytics->breakdownForLink($link, 'os'),
'countries' => $this->analytics->breakdownForLink($link, 'country'),
'referrers' => $this->analytics->referrersForLink($link),
'recentClicks' => $this->analytics->recentClicksForLink($link),
]);
}
+19
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Support\Link\LinkGeoResolver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,6 +13,9 @@ class LinkClick extends Model
'ip_hash',
'user_agent',
'referer',
'device_type',
'browser',
'os',
'country',
'is_unique',
'clicked_at',
@@ -26,4 +30,19 @@ class LinkClick extends Model
{
return $this->belongsTo(ShortLink::class);
}
public function contextLabel(): string
{
$parts = array_filter([
$this->device_type ? ucfirst($this->device_type) : null,
$this->browser,
$this->os,
$this->country ? LinkGeoResolver::countryLabel($this->country) : null,
]);
$context = $parts !== [] ? implode(' · ', $parts) : null;
$referer = $this->referer ? parse_url($this->referer, PHP_URL_HOST) ?: $this->referer : 'Direct';
return $context ? "{$context} · via {$referer}" : "via {$referer}";
}
}
+117 -4
View File
@@ -5,11 +5,15 @@ namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\ShortLink;
use App\Models\User;
use App\Support\Link\LinkGeoResolver;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LinkAnalyticsService
{
/** @var list<string> */
private const BREAKDOWN_COLUMNS = ['device_type', 'browser', 'os', 'country'];
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */
public function summaryForAccount(User $account): array
{
@@ -25,12 +29,43 @@ class LinkAnalyticsService
];
}
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,last_clicked_at:\Illuminate\Support\Carbon|null} */
public function summaryForLink(ShortLink $link): array
{
$clicks = LinkClick::query()->where('short_link_id', $link->id);
return [
'total_clicks' => (int) $link->clicks_total,
'unique_clicks' => (int) $link->unique_clicks_total,
'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(),
'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(),
'last_clicked_at' => $link->last_clicked_at,
];
}
/** @return Collection<int, object{date:string,total:int}> */
public function dailyClicksForAccount(User $account, int $days = 30): Collection
{
$rows = LinkClick::query()
return $this->dailyClicks(
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
$days,
);
}
/** @return Collection<int, object{date:string,total:int}> */
public function dailyClicksForLink(ShortLink $link, int $days = 30): Collection
{
return $this->dailyClicks(
LinkClick::query()->where('short_link_id', $link->id),
$days,
);
}
/** @return Collection<int, object{date:string,total:int}> */
private function dailyClicks($query, int $days): Collection
{
$rows = (clone $query)
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay())
->groupBy('date')
->orderBy('date')
@@ -47,6 +82,48 @@ class LinkAnalyticsService
});
}
/** @return array<int, array{label:string,total:int}> */
public function breakdownForAccount(User $account, string $column, int $limit = 8): array
{
return $this->breakdown(
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
$column,
$limit,
);
}
/** @return array<int, array{label:string,total:int}> */
public function breakdownForLink(ShortLink $link, string $column, int $limit = 8): array
{
return $this->breakdown(
LinkClick::query()->where('short_link_id', $link->id),
$column,
$limit,
);
}
/** @return array<int, array{label:string,total:int}> */
private function breakdown($query, string $column, int $limit): array
{
if (! in_array($column, self::BREAKDOWN_COLUMNS, true)) {
throw new \InvalidArgumentException("Unsupported breakdown column: {$column}");
}
return (clone $query)
->select($column, DB::raw('COUNT(*) as total'))
->whereNotNull($column)
->where($column, '!=', '')
->groupBy($column)
->orderByDesc('total')
->limit($limit)
->get()
->map(fn ($row) => [
'label' => $this->breakdownLabel($column, (string) $row->{$column}),
'total' => (int) $row->total,
])
->all();
}
/** @return Collection<int, array{label:string,total:int}> */
public function topLinksForAccount(User $account, int $limit = 8): Collection
{
@@ -74,12 +151,39 @@ class LinkAnalyticsService
->get();
}
/** @return Collection<int, LinkClick> */
public function recentClicksForLink(ShortLink $link, int $limit = 20): Collection
{
return LinkClick::query()
->where('short_link_id', $link->id)
->latest('clicked_at')
->limit($limit)
->get();
}
/** @return Collection<int, array{label:string,total:int}> */
public function referrersForAccount(User $account, int $limit = 8): Collection
{
return LinkClick::query()
return $this->referrers(
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
$limit,
);
}
/** @return Collection<int, array{label:string,total:int}> */
public function referrersForLink(ShortLink $link, int $limit = 8): Collection
{
return $this->referrers(
LinkClick::query()->where('short_link_id', $link->id),
$limit,
);
}
/** @return Collection<int, array{label:string,total:int}> */
private function referrers($query, int $limit): Collection
{
return (clone $query)
->select('referer', DB::raw('COUNT(*) as total'))
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->whereNotNull('referer')
->where('referer', '!=', '')
->groupBy('referer')
@@ -92,6 +196,15 @@ class LinkAnalyticsService
]);
}
private function breakdownLabel(string $column, string $value): string
{
return match ($column) {
'device_type' => ucfirst($value),
'country' => LinkGeoResolver::countryLabel($value),
default => $value,
};
}
private function refererLabel(string $referer): string
{
$host = parse_url($referer, PHP_URL_HOST);
+9 -1
View File
@@ -5,6 +5,8 @@ namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\LinkWallet;
use App\Models\ShortLink;
use App\Support\Link\LinkGeoResolver;
use App\Support\Link\LinkUserAgentParser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -20,12 +22,18 @@ class LinkClickRecorder
->where('clicked_at', '>=', now()->subHours($windowHours))
->exists();
DB::transaction(function () use ($link, $request, $ipHash, $isUnique) {
$parsed = LinkUserAgentParser::parse((string) $request->userAgent());
DB::transaction(function () use ($link, $request, $ipHash, $isUnique, $parsed) {
LinkClick::create([
'short_link_id' => $link->id,
'ip_hash' => $ipHash,
'user_agent' => $this->truncate($request->userAgent(), 512),
'referer' => $this->truncate($request->header('referer'), 512),
'device_type' => $parsed['device_type'],
'browser' => $parsed['browser'],
'os' => $parsed['os'],
'country' => LinkGeoResolver::countryCode($request),
'is_unique' => $isUnique,
'clicked_at' => now(),
]);
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Support\Link;
use Illuminate\Http\Request;
final class LinkGeoResolver
{
public static function countryCode(Request $request): ?string
{
foreach (['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country'] as $header) {
$value = $request->header($header);
if (! is_string($value) || strlen($value) !== 2) {
continue;
}
$code = strtoupper($value);
if (! in_array($code, ['XX', 'T1'], true)) {
return $code;
}
}
return null;
}
public static function countryLabel(?string $code): string
{
if ($code === null || $code === '') {
return 'Unknown';
}
if (function_exists('locale_get_display_region')) {
$name = locale_get_display_region('_'.$code, 'en');
return is_string($name) && $name !== '' ? $name : $code;
}
return $code;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Support\Link;
final class LinkUserAgentParser
{
/**
* @return array{device_type: string, browser: string, os: string}
*/
public static function parse(string $userAgent): array
{
$uaLower = strtolower($userAgent);
$device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone')
? 'mobile'
: (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop');
$browser = match (true) {
str_contains($uaLower, 'edg/') => 'Edge',
str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome',
str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari',
str_contains($uaLower, 'firefox/') => 'Firefox',
str_contains($uaLower, 'instagram') => 'Instagram',
str_contains($uaLower, 'whatsapp') => 'WhatsApp',
default => 'Other',
};
$os = match (true) {
str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS',
str_contains($uaLower, 'android') => 'Android',
str_contains($uaLower, 'windows') => 'Windows',
str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS',
str_contains($uaLower, 'linux') => 'Linux',
default => 'Other',
};
return [
'device_type' => $device,
'browser' => $browser,
'os' => $os,
];
}
}