Skip to content

Commit 786116d

Browse files
authored
Merge pull request #57 from me-shaon/feat/ip-referrer-filter
feat: add skip ip and referrer option
2 parents 82e66b6 + 83a8b9b commit 786116d

File tree

4 files changed

+309
-0
lines changed

4 files changed

+309
-0
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ return [
103103

104104
'ignore-paths' => [
105105
env('REQUEST_ANALYTICS_PATHNAME', 'analytics'),
106+
'broadcasting/auth',
107+
'livewire/*',
108+
],
109+
110+
'skip_ips' => [
111+
// '127.0.0.1',
112+
// '192.168.1.0/24',
113+
// Add IP addresses or CIDR blocks to skip tracking
114+
],
115+
116+
'skip_referrers' => [
117+
// 'spam-site.com',
118+
// 'unwanted-referrer.com',
119+
// Add referrer domains to skip tracking
106120
],
107121

108122
'pruning' => [
@@ -213,6 +227,8 @@ protected function schedule(Schedule $schedule): void
213227

214228
### Intelligence & Detection
215229
- **Advanced Bot Detection**: Filters search engines, social bots, and crawlers
230+
- **IP Address Filtering**: Skip tracking for specific IP addresses or CIDR blocks (e.g., internal IPs, admin IPs)
231+
- **Referrer Filtering**: Exclude tracking for requests from specific referrer domains (e.g., spam sites, unwanted sources)
216232
- **Device Recognition**: Browser, OS, and device type identification
217233
- **Geolocation Services**: Multiple provider support (IP-API, IPGeolocation, MaxMind)
218234
- **Visitor Tracking**: Cookie-based unique visitor identification
@@ -262,6 +278,12 @@ php artisan model:prune --model="MeShaon\RequestAnalytics\Models\RequestAnalytic
262278
### Path Filtering
263279
- `ignore-paths`: Array of paths to exclude from tracking (e.g., admin routes, health checks)
264280

281+
### IP and Referrer Filtering
282+
Filter out unwanted traffic to improve data quality and protect privacy by excluding specific IP addresses and referrer domains from analytics tracking.
283+
284+
- `skip_ips`: Array of IP addresses or CIDR blocks to skip tracking. Supports exact IP matches (e.g., `'127.0.0.1'`) and CIDR notation for IP ranges (e.g., `'192.168.1.0/24'`). Useful for excluding internal networks, admin IPs, or development environments.
285+
- `skip_referrers`: Array of referrer domains to exclude from tracking. Filters out spam referrers, bot traffic, or internal tools (e.g., `'spam-site.com'`, `'internal-tool.com'`) to maintain clean analytics data.
286+
265287
### Data Retention
266288
- `pruning.enabled`: Automatic data cleanup (default: `true`)
267289
- `pruning.days`: Days to retain data (default: 90)

config/request-analytics.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@
4040
'livewire/*',
4141
],
4242

43+
'skip_ips' => [
44+
// '127.0.0.1',
45+
// '192.168.1.0/24',
46+
// Add IP addresses or CIDR blocks to skip tracking
47+
],
48+
49+
'skip_referrers' => [
50+
// 'spam-site.com',
51+
// 'unwanted-referrer.com',
52+
// Add referrer domains to skip tracking
53+
],
54+
4355
'pruning' => [
4456
'enabled' => env('REQUEST_ANALYTICS_PRUNING_ENABLED', true),
4557
'days' => env('REQUEST_ANALYTICS_PRUNING_DAYS', 90),

src/Traits/CaptureRequest.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use MeShaon\RequestAnalytics\Services\BotDetectionService;
88
use MeShaon\RequestAnalytics\Services\GeolocationService;
99
use MeShaon\RequestAnalytics\Services\VisitorTrackingService;
10+
use Symfony\Component\HttpFoundation\IpUtils;
1011
use Symfony\Component\HttpFoundation\Response;
1112

1213
trait CaptureRequest
@@ -22,6 +23,16 @@ public function capture(Request $request, Response $response, string $requestCat
2223
return null;
2324
}
2425

26+
// Skip if IP address is in the skip list
27+
if ($this->shouldSkipIp($request)) {
28+
return null;
29+
}
30+
31+
// Skip if referrer is in the skip list
32+
if ($this->shouldSkipReferrer($request)) {
33+
return null;
34+
}
35+
2536
return $this->prepareRequestData($request, $response, $requestCategory);
2637
}
2738

@@ -249,4 +260,69 @@ protected function isBot(Request $request): bool
249260
$request->ip()
250261
);
251262
}
263+
264+
protected function shouldSkipIp(Request $request): bool
265+
{
266+
$skipIps = config('request-analytics.skip_ips', []);
267+
268+
if (empty($skipIps)) {
269+
return false;
270+
}
271+
272+
$clientIp = $request->ip();
273+
274+
foreach ($skipIps as $skipIp) {
275+
// IpUtils::checkIp handles both exact IPs and CIDR ranges for IPv4 and IPv6
276+
if (IpUtils::checkIp($clientIp, $skipIp)) {
277+
return true;
278+
}
279+
}
280+
281+
return false;
282+
}
283+
284+
protected function shouldSkipReferrer(Request $request): bool
285+
{
286+
$skipReferrers = config('request-analytics.skip_referrers', []);
287+
288+
if (empty($skipReferrers)) {
289+
return false;
290+
}
291+
292+
$referrer = $request->header('referer', '');
293+
294+
if (empty($referrer)) {
295+
return false;
296+
}
297+
298+
// Extract domain from referrer URL
299+
$referrerDomain = $this->extractDomainFromUrl($referrer);
300+
301+
foreach ($skipReferrers as $skipReferrer) {
302+
// Check exact match or if referrer domain is a subdomain of skip domain
303+
if ($referrerDomain === $skipReferrer || str_ends_with($referrerDomain, '.'.$skipReferrer)) {
304+
return true;
305+
}
306+
}
307+
308+
return false;
309+
}
310+
311+
protected function extractDomainFromUrl(string $url): string
312+
{
313+
$parsed = parse_url($url);
314+
315+
if (! isset($parsed['host'])) {
316+
return '';
317+
}
318+
319+
$host = strtolower($parsed['host']);
320+
321+
// Remove www. prefix if present
322+
if (str_starts_with($host, 'www.')) {
323+
return substr($host, 4);
324+
}
325+
326+
return $host;
327+
}
252328
}

tests/Unit/Traits/CaptureRequestTest.php

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,26 @@ public function testIsBot(Request $request): bool
6868
{
6969
return $this->isBot($request);
7070
}
71+
72+
public function testShouldSkipIp(Request $request): bool
73+
{
74+
return $this->shouldSkipIp($request);
75+
}
76+
77+
public function testShouldSkipReferrer(Request $request): bool
78+
{
79+
return $this->shouldSkipReferrer($request);
80+
}
81+
82+
public function testIpInCidrRange(string $ip, string $cidr): bool
83+
{
84+
return $this->ipInCidrRange($ip, $cidr);
85+
}
86+
87+
public function testExtractDomainFromUrl(string $url): string
88+
{
89+
return $this->extractDomainFromUrl($url);
90+
}
7191
};
7292
}
7393

@@ -302,4 +322,183 @@ public function it_detects_bots_correctly(): void
302322
$this->assertFalse($this->traitClass->testIsBot($humanRequest));
303323
$this->assertTrue($this->traitClass->testIsBot($botRequest));
304324
}
325+
326+
#[Test]
327+
public function it_returns_null_when_ip_should_be_skipped(): void
328+
{
329+
config(['request-analytics.skip_ips' => ['127.0.0.1', '192.168.1.100']]);
330+
331+
$request = Request::create('/test', 'GET');
332+
$request->server->set('REMOTE_ADDR', '127.0.0.1');
333+
$response = new Response('content');
334+
335+
$result = $this->traitClass->testCapture($request, $response, 'web');
336+
337+
$this->assertNull($result);
338+
}
339+
340+
#[Test]
341+
public function it_returns_null_when_ip_is_in_cidr_range(): void
342+
{
343+
config(['request-analytics.skip_ips' => ['192.168.1.0/24']]);
344+
345+
$request = Request::create('/test', 'GET');
346+
$request->server->set('REMOTE_ADDR', '192.168.1.50');
347+
$response = new Response('content');
348+
349+
$result = $this->traitClass->testCapture($request, $response, 'web');
350+
351+
$this->assertNull($result);
352+
}
353+
354+
#[Test]
355+
public function it_returns_null_when_referrer_should_be_skipped(): void
356+
{
357+
config(['request-analytics.skip_referrers' => ['spam-site.com', 'unwanted-referrer.com']]);
358+
359+
$request = Request::create('/test', 'GET');
360+
$request->headers->set('referer', 'https://spam-site.com/page');
361+
$response = new Response('content');
362+
363+
$result = $this->traitClass->testCapture($request, $response, 'web');
364+
365+
$this->assertNull($result);
366+
}
367+
368+
#[Test]
369+
public function it_returns_data_when_ip_is_not_in_skip_list(): void
370+
{
371+
config([
372+
'request-analytics.skip_ips' => ['127.0.0.1'],
373+
'request-analytics.geolocation.enabled' => false,
374+
'request-analytics.capture.bots' => true,
375+
]);
376+
377+
$request = Request::create('/test', 'GET');
378+
$request->server->set('REMOTE_ADDR', '192.168.1.100');
379+
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
380+
$response = new Response('content');
381+
382+
$result = $this->traitClass->testCapture($request, $response, 'web');
383+
384+
$this->assertInstanceOf(RequestDataDTO::class, $result);
385+
}
386+
387+
#[Test]
388+
public function it_returns_data_when_referrer_is_not_in_skip_list(): void
389+
{
390+
config([
391+
'request-analytics.skip_referrers' => ['spam-site.com'],
392+
'request-analytics.geolocation.enabled' => false,
393+
'request-analytics.capture.bots' => true,
394+
]);
395+
396+
$request = Request::create('/test', 'GET');
397+
$request->headers->set('referer', 'https://example.com/page');
398+
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
399+
$response = new Response('content');
400+
401+
$result = $this->traitClass->testCapture($request, $response, 'web');
402+
403+
$this->assertInstanceOf(RequestDataDTO::class, $result);
404+
}
405+
406+
#[Test]
407+
public function it_should_skip_ip_correctly(): void
408+
{
409+
config(['request-analytics.skip_ips' => ['127.0.0.1', '10.0.0.1']]);
410+
411+
$skipRequest = Request::create('/test', 'GET');
412+
$skipRequest->server->set('REMOTE_ADDR', '127.0.0.1');
413+
414+
$allowRequest = Request::create('/test', 'GET');
415+
$allowRequest->server->set('REMOTE_ADDR', '192.168.1.1');
416+
417+
$this->assertTrue($this->traitClass->testShouldSkipIp($skipRequest));
418+
$this->assertFalse($this->traitClass->testShouldSkipIp($allowRequest));
419+
}
420+
421+
#[Test]
422+
public function it_should_skip_referrer_correctly(): void
423+
{
424+
config(['request-analytics.skip_referrers' => ['spam-site.com', 'unwanted.com']]);
425+
426+
$skipRequest = Request::create('/test', 'GET');
427+
$skipRequest->headers->set('referer', 'https://spam-site.com/some-page');
428+
429+
$skipWwwRequest = Request::create('/test', 'GET');
430+
$skipWwwRequest->headers->set('referer', 'https://www.spam-site.com/some-page');
431+
432+
$allowRequest = Request::create('/test', 'GET');
433+
$allowRequest->headers->set('referer', 'https://example.com/some-page');
434+
435+
$noReferrerRequest = Request::create('/test', 'GET');
436+
437+
$this->assertTrue($this->traitClass->testShouldSkipReferrer($skipRequest));
438+
$this->assertTrue($this->traitClass->testShouldSkipReferrer($skipWwwRequest));
439+
$this->assertFalse($this->traitClass->testShouldSkipReferrer($allowRequest));
440+
$this->assertFalse($this->traitClass->testShouldSkipReferrer($noReferrerRequest));
441+
}
442+
443+
#[Test]
444+
#[DataProvider('cidrRangeProvider')]
445+
public function it_checks_ip_in_cidr_range_correctly(string $ip, string $cidr, bool $expected): void
446+
{
447+
$result = $this->traitClass->testIpInCidrRange($ip, $cidr);
448+
449+
$this->assertEquals($expected, $result);
450+
}
451+
452+
public static function cidrRangeProvider(): array
453+
{
454+
return [
455+
['192.168.1.1', '192.168.1.0/24', true],
456+
['192.168.1.255', '192.168.1.0/24', true],
457+
['192.168.2.1', '192.168.1.0/24', false],
458+
['10.0.0.1', '10.0.0.0/8', true],
459+
['11.0.0.1', '10.0.0.0/8', false],
460+
['172.16.0.1', '172.16.0.0/16', true],
461+
['172.17.0.1', '172.16.0.0/16', false],
462+
['127.0.0.1', '127.0.0.0/8', true],
463+
];
464+
}
465+
466+
#[Test]
467+
#[DataProvider('domainExtractionProvider')]
468+
public function it_extracts_domain_from_url_correctly(string $url, string $expected): void
469+
{
470+
$result = $this->traitClass->testExtractDomainFromUrl($url);
471+
472+
$this->assertEquals($expected, $result);
473+
}
474+
475+
public static function domainExtractionProvider(): array
476+
{
477+
return [
478+
['https://example.com', 'example.com'],
479+
['https://www.example.com', 'example.com'],
480+
['http://subdomain.example.com', 'subdomain.example.com'],
481+
['https://www.subdomain.example.com', 'subdomain.example.com'],
482+
['https://example.com/path/to/page', 'example.com'],
483+
['https://www.example.com/path?query=value#fragment', 'example.com'],
484+
['ftp://files.example.com', 'files.example.com'],
485+
['invalid-url', ''],
486+
];
487+
}
488+
489+
#[Test]
490+
public function it_handles_empty_skip_lists_correctly(): void
491+
{
492+
config([
493+
'request-analytics.skip_ips' => [],
494+
'request-analytics.skip_referrers' => [],
495+
]);
496+
497+
$request = Request::create('/test', 'GET');
498+
$request->server->set('REMOTE_ADDR', '127.0.0.1');
499+
$request->headers->set('referer', 'https://any-site.com');
500+
501+
$this->assertFalse($this->traitClass->testShouldSkipIp($request));
502+
$this->assertFalse($this->traitClass->testShouldSkipReferrer($request));
503+
}
305504
}

0 commit comments

Comments
 (0)