Коли в аналітиці ICanUp з'явився розподіл за країнами, я зіткнулася з важливим питанням: звідки брати країну відвідувача, якщо я принципово не хочу зберігати його IP-адресу в історії Analytics?
Мені також не подобався варіант, де Laravel на кожен перегляд надсилає IP у зовнішній GeoIP API.
У результаті визначення країни я винесла на infrastructure boundary: Nginx + MaxMind визначають код країни, а застосунок отримує лише готове дволітерне значення.

Мені була потрібна країна, а не IP-адреса
Для аналітики мені важливо бачити, з яких країн приходять перегляди.
Для цього не потрібно зберігати адресу конкретного відвідувача.
Код країни дає потрібний рівень агрегації без створення зайвої історії з необробленими IP-адресами.
Що мені потрібно | Що мені не потрібно |
|---|---|
Код країни | Необроблена IP-адреса |
Агрегована статистика | Точна геолокація людини |
Country breakdown | Місто, координати або профіль відвідувача |
Стабільний infrastructure source | Зовнішній HTTP request на кожен перегляд |
Головне рішення - перенести GeoIP за межі Laravel
Laravel уже має достатньо роботи під час публічного запиту: routing, rendering, analytics tracking та іншу application logic.
Я не хотіла додавати до кожного перегляду ще один HTTP request до зовнішнього GeoIP service.
Визначення країни природніше виконати там, де мережевий запит уже проходить через infrastructure.
Visitor request | vNginx | | visitor IP is used for local GeoIP lookup vMaxMind GeoLite2 Country | | result: UA vtrusted internal country header | vLaravel | vcountry_code = UA | vAnalytics aggregate IP is not persisted in Analytics historyMaxMind працює локально біля Nginx
Для цього підходу підходить локальна база MaxMind GeoLite2 Country разом з GeoIP2 support у Nginx.
GeoIP lookup не потребує відправляти адресу відвідувача сторонньому сервісу на кожен запит.
Lookup відбувається на моїй infrastructure, а результатом для застосунку стає лише код країни.
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb { auto_reload 1h; $geoip2_country_code country iso_code;}Laravel не повинен довіряти заголовку від браузера
Передати код країни через HTTP header зручно, але тут легко створити security bug.
Клієнт сам може надіслати заголовок на кшталт X-Icanup-Country-Code: US.
Тому application не повинен вважати довіреним значення, яке просто прийшло від клієнта.
location ~ \.php$ { include fastcgi_params; fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE $geoip2_country_code; # Existing PHP-FPM configuration...}Ідея полягає в тому, що origin infrastructure сама задає значення, отримане з GeoIP lookup.
Вхідне клієнтське значення не повинно проходити до application як джерело істини.
Назва заголовка залишається конфігурованою
Я не хотіла жорстко прив'язувати Analytics до конкретного header name.
Тому application environment визначає, з якого довіреного заголовка читати код країни.
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-CodeЦе залишає чітку межу: infrastructure вирішує, звідки береться країна, а Analytics знає лише узгоджений application contract.
Resolver отримує вже готовий код країни
На application side країну приймає PublicAnalyticsCountryDimensionResolver.
Йому не потрібно знати структуру MaxMind database, шлях до .mmdb або деталі Nginx.
Resolver працює з маленьким контрактом: довірений header -> нормалізований country_code або null.
$headerName = config( 'analytics.country_header'); $value = $request->header( $headerName); if (! is_string($value)) { return null;} $countryCode = strtoupper( trim($value)); return preg_match( '/^[A-Z]{2}$/', $countryCode,) === 1 ? $countryCode : null;У сховище потрапляє тільки нормалізоване значення
Для Analytics мені достатньо дволітерного коду країни.
Значення нормалізується до стабільного формату, наприклад UA, US або DE.
Довільний рядок не повинен автоматично ставати analytics dimension.
- Приймається тільки очікуваний короткий формат.
- Значення нормалізується перед збереженням.
- Невідоме або відсутнє значення може стати
null. - Відсутність країни не повинна ламати public request.
- IP-адреса не додається в analytics dimensions.
Чому я не використала зовнішній GeoIP API на кожен view
Технічно Laravel міг би взяти IP, викликати зовнішній GeoIP endpoint і отримати країну.
Але це додало б мережеву залежність у кожен eligible page view.
Також IP залишав би мою infrastructure під час такого lookup.
Для простого country breakdown локальна GeoIP database дає значно чистішу межу відповідальності.
Local GeoIP lookup | External API per view |
|---|---|
Немає додаткового HTTP request | Додаткова мережна операція |
IP залишається на infrastructure boundary | IP передається зовнішньому provider |
Передбачувана затримка | Залежність від зовнішнього сервісу |
Потрібно оновлювати локальну database | Потрібно контролювати API limits і availability |
GeoLite2 database не є частиною application repository
Локальна GeoIP database є operational dependency, але це не application source code.
Я не хочу комітити великий binary .mmdb файл у Git разом із Laravel code.
.mmdbзберігається поза application repository.- Шлях до database належить server configuration.
- Nginx process повинен мати права на читання файла.
- Оновлення GeoLite2 потрібно виконувати окремим operational process.
- Dev і production повинні використовувати однаковий header contract.
- Заміна database не повинна вимагати зміни application code.
Відсутня країна не повинна ламати Analytics
GeoIP не дає абсолютної гарантії, що кожну адресу можна перетворити на коректну країну.
Також під час локальної розробки значення може бути відсутнім.
Тому country dimension є додатковою характеристикою перегляду, а не умовою, без якої весь tracking має впасти.
Немає country code - немає country dimension, але сам публічний запит продовжує працювати.
Повний шлях потрібно перевіряти end-to-end
Окремо перевірити MaxMind або окремо перевірити Laravel resolver недостатньо.
Мені потрібно знати, що весь ланцюжок працює від реального public request до агрегованої статистики.
1. Public request reaches Nginx 2. Nginx resolves country with MaxMind 3. Infrastructure creates: X-Icanup-Country-Code: UA 4. Laravel reads the configured trusted header 5. Resolver normalizes: UA 6. Analytics stores: country_code = UA 7. Admin Analytics: Countries -> Ukraine- Новий eligible public view отримує country code через trusted infrastructure path.
PublicAnalyticsCountryDimensionResolverприймає значення.- Новий analytics bucket містить
country_code. - Admin Analytics починає показувати країну.
- Missing country не створює exception.
- Analytics history не містить raw IP.
Підміна country header була окремою security перевіркою
Найважливіша security властивість тут - браузер не може сам обрати собі країну.
Якщо клієнт передасть X-Icanup-Country-Code, origin повинен замінити або відкинути це значення і сформувати власне.
Trusted header стає довіреним не через свою назву. Його робить довіреним контрольована infrastructure path.
Client sends: X-Icanup-Country-Code: XX Do not trust it directly. Infrastructure resolves: MaxMind lookup -> UA Laravel receives trusted value: X-Icanup-Country-Code: UAЩо фактично зберігається в Analytics
Після проходження цього ланцюжка Analytics уже не потребує IP.
У новому aggregate зберігається тільки нормалізований country_code серед додаткових dimensions.
Це і є потрібний мені результат: статистика за країнами без історії з адресами окремих відвідувачів.
SELECT JSON_UNQUOTE( JSON_EXTRACT( additional_dimensions, '$.country_code' ) ) AS country_code, COUNT(*) AS bucketsFROM analytics_daily_aggregatesWHERE JSON_EXTRACT( additional_dimensions, '$.country_code') IS NOT NULLGROUP BY country_codeORDER BY buckets DESC;Країна стає корисною тільки після накопичення даних
Сам факт появи UA у database ще не був кінцевою метою.
Код країни потрібен для агрегованого Countries breakdown в Admin Analytics.
Після появи нових eligible views блок починає показувати реальні накопичені значення.
Я свідомо не розширювала GeoIP до точної геолокації
Після підключення GeoIP технічно легко захотіти більше: region, city, coordinates та інші характеристики.
Але для поточного завдання вони не потрібні.
Мій принцип - збирати найменший набір даних, який відповідає на реальне аналітичне питання.
У scope | Поза scope |
|---|---|
Country code | City |
Country aggregate | Coordinates |
2-letter normalized value | Raw IP history |
Anonymous traffic dimension | Visitor profiling |
Що могло піти не так
- Зберігати IP разом із country code "про всяк випадок".
- Викликати зовнішній GeoIP API для кожного page view.
- Довіряти
X-Icanup-Country-Code, який напряму надіслав клієнт. - Комітити GeoLite2
.mmdbу application repository. - Не оновлювати MaxMind database.
- Зберігати довільний невалідований рядок як
country_code. - Кидати exception, якщо country lookup не дав результату.
- Переходити до city-level tracking без реальної потреби.
Що я залишила собі на майбутнє
- GeoIP resolution краще виконувати на infrastructure boundary.
- Laravel не повинен робити зовнішній GeoIP HTTP request на кожен view.
- Для Countries analytics достатньо дволітерного коду країни.
- Raw IP не потрібен у analytics history.
- Trusted header потрібно формувати або гарантовано перезаписувати на origin.
- Назва header повинна залишатися конфігурованою.
- GeoLite2 database зберігається поза Git repository.
- Missing country є нормальним станом і не повинен ламати tracking.
- Перевіряти потрібно весь шлях від Nginx до Admin Analytics.
- Не збирати точнішу геолокацію без конкретної потреби.
Для статистики за країнами мені не потрібна історія IP-адрес. Мені потрібен надійний спосіб перетворити мережевий запит на мінімальний country code ще до того, як дані потраплять в Analytics.
Висновок
У цьому рішенні найбільш важливою для мене стала не сама GeoIP database, а правильна межа відповідальності.
Nginx бачить мережевий запит, MaxMind локально визначає країну, infrastructure формує довірений внутрішній заголовок, а Laravel отримує тільки дволітерний country_code.
У ICanUp Analytics не зберігає raw IP і не викликає зовнішній GeoIP API для кожного перегляду.
У результаті я отримала потрібний Countries breakdown, не перетворюючи просту статистику за країнами на систему зберігання точніших персональних мережевих даних.



