0% прочитано

GeoIP-аналітика без збереження IP: Nginx + MaxMind і довірений заголовок

Для статистики ICanUp мені була потрібна країна відвідувача, але я не хотіла зберігати його IP-адресу або викликати зовнішній GeoIP API на кожен перегляд. Я перенесла визначення країни на рівень Nginx і MaxMind, а Laravel отримує лише нормалізований дволітерний код через довірений внутрішній заголовок.

11 вересня 2026 р. 8 хв читанняNginx

Коли в аналітиці ICanUp з'явився розподіл за країнами, я зіткнулася з важливим питанням: звідки брати країну відвідувача, якщо я принципово не хочу зберігати його IP-адресу в історії Analytics?

Мені також не подобався варіант, де Laravel на кожен перегляд надсилає IP у зовнішній GeoIP API.

У результаті визначення країни я винесла на infrastructure boundary: Nginx + MaxMind визначають код країни, а застосунок отримує лише готове дволітерне значення.

Без збереження IP не означає, що сервер ніколи не бачить IP-адресу. Nginx отримує мережеве з'єднання і може використати адресу для GeoIP lookup. Важлива межа в іншому: IP не потрапляє в історію Analytics.
Nginx and MaxMind resolve a visitor country before Laravel Analytics stores only the country code
Nginx і MaxMind перетворюють мережеву адресу на код країни до того, як запит потрапляє в Analytics. Laravel зберігає тільки country_code.

Мені була потрібна країна, а не IP-адреса

Для аналітики мені важливо бачити, з яких країн приходять перегляди.

Для цього не потрібно зберігати адресу конкретного відвідувача.

Код країни дає потрібний рівень агрегації без створення зайвої історії з необробленими IP-адресами.

Що мені потрібно

Що мені не потрібно

Код країни UA

Необроблена 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 history

MaxMind працює локально біля Nginx

Для цього підходу підходить локальна база MaxMind GeoLite2 Country разом з GeoIP2 support у Nginx.

GeoIP lookup не потребує відправляти адресу відвідувача сторонньому сервісу на кожен запит.

Lookup відбувається на моїй infrastructure, а результатом для застосунку стає лише код країни.

Nginx - Спрощене підключення GeoLite2 Country
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {    auto_reload 1h;     $geoip2_country_code        country iso_code;}
Це спрощений приклад конфігурації, а не копія всього production nginx.conf. Шлях до .mmdb, спосіб підключення GeoIP2 module і real IP configuration залежать від конкретної infrastructure.

Laravel не повинен довіряти заголовку від браузера

Передати код країни через HTTP header зручно, але тут легко створити security bug.

Клієнт сам може надіслати заголовок на кшталт X-Icanup-Country-Code: US.

Тому application не повинен вважати довіреним значення, яке просто прийшло від клієнта.

Nginx - Nginx формує внутрішній country header
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 визначає, з якого довіреного заголовка читати код країни.

ENV - Налаштування country header
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-Code

Це залишає чітку межу: infrastructure вирішує, звідки береться країна, а Analytics знає лише узгоджений application contract.

Resolver отримує вже готовий код країни

На application side країну приймає PublicAnalyticsCountryDimensionResolver.

Йому не потрібно знати структуру MaxMind database, шлях до .mmdb або деталі Nginx.

Resolver працює з маленьким контрактом: довірений header -> нормалізований country_code або null.

PHP - Спрощена логіка нормалізації country code
$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;
PHP-фрагмент показує принцип resolver-а, а не дослівну копію production class. Infrastructure details не повинні переходити в application layer.

У сховище потрапляє тільки нормалізоване значення

Для 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, але сам публічний запит продовжує працювати.

Відсутність або невідоме значення країни не повинні створювати exception чи блокувати rendering публічної сторінки.

Повний шлях потрібно перевіряти end-to-end

Окремо перевірити MaxMind або окремо перевірити Laravel resolver недостатньо.

Мені потрібно знати, що весь ланцюжок працює від реального public request до агрегованої статистики.

End-to-end verification flow
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 value не є джерелом істини
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.

Це і є потрібний мені результат: статистика за країнами без історії з адресами окремих відвідувачів.

SQL - Приклад перевірки country_code в aggregates
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 без реальної потреби.
Privacy-safe analytics визначається не тим, що система взагалі не бачить мережеву адресу. Важливо, які дані проходять далі, що саме зберігається і скільки часу вони залишаються в системі.

Що я залишила собі на майбутнє

  • 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, не перетворюючи просту статистику за країнами на систему зберігання точніших персональних мережевих даних.