Skip to content

Commit 49154e9

Browse files
committed
feat: add skip ip and referrer option
1 parent 82e66b6 commit 49154e9

File tree

4 files changed

+331
-0
lines changed

4 files changed

+331
-0
lines changed

README.md

Lines changed: 20 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,10 @@ 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+
- `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'`)
283+
- `skip_referrers`: Array of referrer domains to exclude from tracking. Useful for filtering out spam referrers or internal traffic (e.g., `'spam-site.com'`, `'internal-tool.com'`)
284+
265285
### Data Retention
266286
- `pruning.enabled`: Automatic data cleanup (default: `true`)
267287
- `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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ public function capture(Request $request, Response $response, string $requestCat
2222
return null;
2323
}
2424

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

@@ -249,4 +259,94 @@ protected function isBot(Request $request): bool
249259
$request->ip()
250260
);
251261
}
262+
263+
protected function shouldSkipIp(Request $request): bool
264+
{
265+
$skipIps = config('request-analytics.skip_ips', []);
266+
267+
if (empty($skipIps)) {
268+
return false;
269+
}
270+
271+
$clientIp = $request->ip();
272+
273+
foreach ($skipIps as $skipIp) {
274+
// Handle CIDR notation (e.g., 192.168.1.0/24)
275+
if (str_contains($skipIp, '/')) {
276+
if ($this->ipInCidrRange($clientIp, $skipIp)) {
277+
return true;
278+
}
279+
} else {
280+
// Handle exact IP match
281+
if ($clientIp === $skipIp) {
282+
return true;
283+
}
284+
}
285+
}
286+
287+
return false;
288+
}
289+
290+
protected function shouldSkipReferrer(Request $request): bool
291+
{
292+
$skipReferrers = config('request-analytics.skip_referrers', []);
293+
294+
if (empty($skipReferrers)) {
295+
return false;
296+
}
297+
298+
$referrer = $request->header('referer', '');
299+
300+
if (empty($referrer)) {
301+
return false;
302+
}
303+
304+
// Extract domain from referrer URL
305+
$referrerDomain = $this->extractDomainFromUrl($referrer);
306+
307+
foreach ($skipReferrers as $skipReferrer) {
308+
if ($referrerDomain === $skipReferrer) {
309+
return true;
310+
}
311+
}
312+
313+
return false;
314+
}
315+
316+
protected function ipInCidrRange(string $ip, string $cidr): bool
317+
{
318+
[$subnet, $bits] = explode('/', $cidr);
319+
320+
// Convert IP addresses to long integers
321+
$ipLong = ip2long($ip);
322+
$subnetLong = ip2long($subnet);
323+
324+
if ($ipLong === false || $subnetLong === false) {
325+
return false;
326+
}
327+
328+
// Create subnet mask
329+
$mask = -1 << (32 - (int) $bits);
330+
331+
// Apply mask to both IPs and compare
332+
return ($ipLong & $mask) === ($subnetLong & $mask);
333+
}
334+
335+
protected function extractDomainFromUrl(string $url): string
336+
{
337+
$parsed = parse_url($url);
338+
339+
if (! isset($parsed['host'])) {
340+
return '';
341+
}
342+
343+
$host = strtolower($parsed['host']);
344+
345+
// Remove www. prefix if present
346+
if (str_starts_with($host, 'www.')) {
347+
$host = substr($host, 4);
348+
}
349+
350+
return $host;
351+
}
252352
}

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)