Одна з небезпечних рис аналітики в тому, що неправильний запис даних часто не ламає сторінку і не викликає exception.
Усе продовжує працювати. Просто метрики повільно стають менш точними.
У моєму випадку проблема проявилась в одному сценарії: внутрішня активність могла потрапити в публічну статистику.
Найпростіший fix виглядав очевидно: додати ще одну перевірку безпосередньо в цей tracker.
Але це вирішило б лише місце, де проблема стала видимою, а не саму причину.
Якщо правило стосується всієї публічної аналітики, воно не повинно бути відповідальністю кожного окремого tracker.
Проблема може проявитися лише в одному трекері
final class SearchAnalyticsTracker{ public function track( Request $request, ?User $user, ): void { if ( $user !== null && $user->canAccessAdmin() ) { return; } $this->storeSearch( $request, );Такий код може бути повністю правильним локально.
Він не записує внутрішню активність у конкретний тип аналітики, і тест на цей сценарій проходить.
Проблема починається тоді, коли в системі вже є інші точки збору даних:
- перегляди контенту;
- пошук;
- поширення;
- кліки;
- інші публічні взаємодії.
Якщо кожен tracker сам вирішує, кого рахувати, однакове правило починає існувати в кількох реалізаціях.
Дублюється не просто if, а правило даних
View tracker | +--> internal user? -> skip Search tracker | +--> internal user? -> skip Share tracker | +--> internal user? -> skip Future trackerТут ризик не в кількох зайвих рядках коду.
Ризик у тому, що різні метрики можуть почати працювати за різними правилами.
Наприклад:
перегляди вже виключають внутрішній трафік;
пошук ще ні;
нова метрика поширень взагалі не знає про це правило;
через кілька місяців з’являється ще один tracker і повторює ту саму помилку.
У результаті дашборд може показувати числа, які формально правильні для кожного query, але семантично несумісні між собою.
Я винесла рішення в одну межу відповідності
final class PublicAnalyticsEligibility{ public function allows( ?User $user, ): bool { if ($user === null) { return true; } return ! $user->hasInternalAccess(); }}Назва класу тут не головна.
Головне, що система отримує одну відповідь на питання: чи має ця взаємодія право потрапити в публічну аналітику?
Після цього tracker більше не вирішує, хто є внутрішнім користувачем, які ролі існують і як визначається доступ до внутрішньої частини застосунку.
Він просто питає policy.
Tracker повертається до своєї основної відповідальності
final class SearchAnalyticsTracker{ public function __construct( private PublicAnalyticsEligibility $eligibility, ) {} public function track( Request $request, ?User $user, ): void { if ( ! $this->eligibility->allows($user) ) { return; } Tracker все ще перевіряє право на участь перед записом.
Але тепер він не володіє самим правилом.
Його відповідальність залишається локальною: нормалізувати дані конкретної події і передати їх у сховище аналітики.
Рішення про те, чи ця подія взагалі належить до публічної статистики, знаходиться вище.
Я не визначаю внутрішнього користувача за назвою ролі
if ( $user->role === 'admin') { return;}if ( ! $eligibility->allows($user)) { return;}Ще одна важлива деталь: право на участь не повинно залежати від випадкової назви ролі.
Сьогодні внутрішній доступ може мати роль admin. Завтра з’явиться moderator, support або editor.
Якщо аналітика перевіряє конкретний role name, новий тип внутрішнього користувача легко почне потрапляти в публічні метрики.
Тому правило має спиратися на реальну capability або інший канонічний сигнал, який відповідає на питання про внутрішній доступ.
Виключення внутрішнього трафіку не означає виключити всіх автентифікованих користувачів
Guest -> eligible Звичайний authenticated user -> eligible Authenticated internal user -> excludedМені також важливо не робити правило занадто широким.
Факт автентифікації сам по собі не означає, що трафік внутрішній.
Звичайний користувач сайту може бути авторизований, читати матеріали, шукати, поширювати контент і залишатися частиною реальної аудиторії.
Тому policy повинна відповідати саме бізнесовій семантиці "internal traffic", а не використовувати грубу перевірку auth()->check().
Спільна policy має покривати всі точки запису
Public interaction | vshared eligibility | +--> excluded -> stop | +--> eligible | +--> view tracker +--> search tracker +--> share tracker +--> future trackerСам факт створення policy ще нічого не гарантує.
Потрібно знайти всі реальні місця, де публічна аналітика записує дані.
Інакше частина системи використовуватиме нову policy, а старий tracker продовжить жити за власними правилами.
Тому після такого рефакторингу я роблю не тільки локальний fix, а й невеликий audit write boundaries.
Більшість правил я тестую біля самої політики відповідності
it('allows guests', function () { expect( $this->eligibility->allows(null) )->toBeTrue();}); it('allows regular users', function () { $user = User::factory()->create(); expect( $this->eligibility->allows($user) )->toBeTrue();}); it('excludes internal users', function () { $user = User::factory()->create();Після централізації немає сенсу копіювати повний набір однакових eligibility-тестів у кожен tracker.
Основну матрицю я перевіряю біля policy:
guest дозволений;
звичайний authenticated user дозволений;
internal user виключений;
різні способи отримання внутрішньої можливості дають однаковий результат.
А біля окремих tracker залишаю представницькі integration-тести, щоб переконатися, що вони справді використовують спільну policy.
Нова метрика автоматично отримує старе правило
Для мене це одна з головних переваг такого рішення.
Коли через кілька місяців з’являється нова метрика, розробнику не потрібно пам’ятати всю історію попередніх багів.
Якщо новий tracker входить у той самий analytics pipeline і залежить від спільної eligibility policy, правило застосовується одразу.
Архітектура починає захищати систему від забутої перевірки.
Це правило не завжди варто переносити в HTTP middleware
Може виникнути спокуса винести перевірку ще вище, наприклад у middleware.
Але eligibility аналітики не завжди є правилом доступу до HTTP request.
Внутрішній користувач цілком може відкрити публічну сторінку. Ми просто не хочемо рахувати цю взаємодію як поведінку зовнішньої аудиторії.
Тому я залишаю HTTP access і analytics eligibility окремими поняттями.
Виправлення збору не означає автоматично переписувати історію
Ще одна межа, яку я залишаю окремою: майбутній збір даних і вже записана історія.
Якщо ми знайшли, що певний тип внутрішнього трафіку раніше міг потрапляти в статистику, це не означає, що потрібно одразу масово переписувати старі aggregates.
Для історичного cleanup потрібні окремі докази, можливість точно ідентифікувати неправильні записи і розуміння того, як перерахунок вплине на звіти.
Спочатку я закриваю правильність нового запису. Історичну корекцію розглядаю окремо.
Що я перевіряю, коли знаходжу локальний analytics bug
Коли проблема проявляється в одному tracker, я перевіряю:
- Чи правило справді локальне для цієї метрики?
- Чи така сама умова повинна діяти для інших public analytics events?
- Чи є один канонічний спосіб визначити eligibility?
- Чи не використовую я role name замість реальної capability?
- Чи всі analytics write boundaries використовують однакову policy?
- Чи залишаються guests і звичайні authenticated users eligible?
- Чи не змішую я analytics eligibility з authorization доступу до сторінки?
- Чи новий tracker автоматично отримає це правило?
Локальний bug іноді показує відсутню системну межу
Початкова проблема виглядала маленькою: один тип внутрішньої активності потрапляє не в ті цифри.
Але локальний if лише заховав би системну проблему.
Публічна аналітика потребує спільного визначення того, які взаємодії взагалі є частиною її аудиторії.
Коли це правило живе в одному місці, окремі tracker залишаються простішими, метрики мають однакову семантику, а нові точки збору даних значно важче реалізувати неправильно.
Для мене хороший fix тут був не "додати ще одну перевірку", а "зробити так, щоб цю перевірку більше не потрібно було пам’ятати в кожному новому tracker".
Якщо одна умова визначає правильність усіх метрик, вона не повинна бути приватною деталлю одного tracker.



