Коли я додала News у ICanUp, з’явилося просте продуктове питання: яку новину показувати користувачу помітніше на Home?
Для авторизованого користувача логічно не показувати одну й ту саму News як "нову" нескінченно. Але тут дуже легко зробити неправильний висновок: якщо картку вже показали, це не означає, що новину прочитали.
Ще важливіше не переносити цю персоналізацію автоматично на гостей через IP, cookie або fingerprint. Відсутність ідентифікації облікового запису - це не проблема, яку обов’язково потрібно виправляти прихованою ідентифікацією.
Shown, Seen, Opened, Read і View відповідають на різні питання
Проблема починається, коли всі ці слова використовуються як синоніми.
Назва стану новини | Що він означає | Чого він не доводить |
|---|---|---|
shown | News потрапила у відповідь або була відрендерена як картка. | Що користувач реально звернув на неї увагу. |
seen | Продукт вирішив вважати цю News уже показаною конкретному відомому користувачу. | Що користувач відкрив або прочитав матеріал. |
opened | Користувач перейшов на сторінку News. | Що він дочитав її до кінця. |
read | Сильніша продуктова семантика завершеного читання, якщо продукт її явно визначає. | Її не можна автоматично отримати лише з факту відкриття сторінки. |
view | Analytics зафіксував прийнятний перегляд публічного контенту. | Що це унікальна людина або що матеріал був прочитаний. |
Якщо два стани змінюють різну поведінку продукту, їм краще мати різні назви й різні правила.
View у ICanUp виникає після відкриття public News
У поточній реалізації ICanUp public News tracking знаходиться на сторінці самої новини.
ShowNewsAction спочатку отримує доступну News через публічний сервіс і лише для реально публічної новини передає subject у PublicContentViewTracker.
Тобто сама присутність News-картки на Головній не є переглядом сторінки новини.
public function __invoke( Request $request, News $news,): Response { $news = $this->newsService->getNews( $news, $request->user()?->can( PermissionEnum::NEWS_PREVIEW->value ) ?? false, ); if ($news->isPubliclyVisible()) { $this->publicContentViewTracker->track( $request, AnalyticsSubject::entity( ContentSubjectTypeEnum::NEWS->definition(),Seen не повинен бути псевдонімом Analytics view
Analytics і персоналізація мають різні цілі.
Analytics відповідає на агреговані питання: скільки було переглядів, яка динаміка, які типи контенту читають, які канали приводять трафік.
Персоналізація відповідає на інше питання: що показати конкретному користувачу зараз?
Якщо використовувати історію аналітики як продуктовий стан, ці два контракти починають протікати один в одного. Зміна дедуплікрваного вікна може несподівано змінити поведінку Головної, а очистка історії аналітики - повернути вже показані новини.
Deduplicated view теж не означає Read
У ICanUp кожен прийнятий перегляд спочатку записується як звичайне вимірювання. Далі окрема deduplication стратегія вирішує, чи потрібно додати супутній показник deduplicated_views.
Це корисно для статистики, але семантика залишається аналітичною. Дедублікований перегляд не перетворюється на доказ того, що конкретна людина прочитала новину.
public function track( TrackAnalyticsInput $input,): ?AnalyticsDeduplicationDecisionEnum { $this->history->record( $input->measurement() ); $context = $input->deduplication(); if ($context === null) { return null; } $decision = $this->deduplication->claim(Для авторизованого користувача stable identity вже існує
Для авторизованого користувача ситуація інша. Система вже знає, кому належить персоналізований state.
Якщо продукту потрібно не повторювати одну й ту саму новину на Головній, можна мати окрему історію контактів для user + news. Вона відповідає лише за персоналізацію.
Новину показали у виділеній позиції - можна змінити
seen.Користувач відкрив новину - це окремий факт.
Analytics записав перегляд - це окрема система вимірювання.
Якщо колись з’явиться реальний read contract, він теж не повинен виникати автоматично з
seen.
Так продукт може вибрати наступну непереглянуту новину без змішування personalization state з Analytics.
Для гостя я не хочу створювати News history через fingerprint
Для гостя немає account identity. Можна було б спробувати створити її заміну: cookie, IP-based key, browser fingerprint або інший довгоживучий ідентифікатор.
Але для задачі "покажи мені іншу новину" це надто велика ціна.
Я віддаю перевагу слабшій персоналізації: гість може отримати актуальну публічну News без постійної історії того, що саме йому колись показували. Після авторизації вже з’являється нормальний owner для персонального state.
Authenticated user | +--> stable account identity exists | +--> product may remember News exposure | +--> next Home request can prefer unseen News Guest | +--> no account identity | +--> do not create persistent News-seen profile | +--> choose eligible News without durable guest historyAnalytics fingerprint має вузьку відповідальність
PublicContentViewTracker передає privacy-safe visitor value у deduplication context разом із subject і locale.
Цей механізм потрібен, щоб не рахувати кожен повторний запит як новий deduplicated view у межах deduplication window.
Він не повинен відповідати на питання "яку Новину цьому гостю показували минулого тижня?". Це вже інший продуктовий контракт.
return $this->tracker->track( TrackAnalyticsInput::withDeduplication( measurement: $measurement, deduplicatedMetric: ContentMetricEnum::DEDUPLICATED_VIEWS->metric(), visitor: $this->fingerprints->forRequest($request), scope: AnalyticsDeduplicationScope::subjectAndLocale( $locale ), ));Чому не варто ставити seen у момент SQL select
Є ще одна важлива межа між вибором News і зміною state.
Query, який шукає "найновішу непереглянуту новину", не повинен автоматично означати, що користувач її вже побачив.
Причин кілька:
SSR може сформувати відповідь, яку користувач фактично не побачить.
Попередня вибірка не означає реальний показ.
Помилка frontend після відповіді не означає успішну експозицію.
Повторний server render не повинен безконтрольно спалювати unseen queue.
Тому вибір і контакт краще вважати двома різними діями, навіть якщо в конкретному UI вони відбуваються дуже близько одна до одної.
Read потребує власного визначення
Слово read виглядає очевидним, але технічно воно найбільш неоднозначне.
Чи достатньо відкрити сторінку? Провести на ній 10 секунд? Доскролити до 80%? Побачити останній ContentBlock? Натиснути "позначити прочитаним"?
Поки продукт не має чіткої відповіді, я б не створювала автоматичний News read state. Краще мати точний opened або analytics view, ніж назвати "прочитаним" те, чого система насправді не знає.
Не кожен стан, який технічно можна записати, варто називати тим, чого система насправді не знає.
Яку межу я залишаю між продуктом і Analytics
Потреба | Де їй місце |
|---|---|
Не повторювати одну й ту саму News авторизованому користувачу | Окремий user-owned personalization state |
Показати public News гостю | Public selection без durable guest profile |
Порахувати відкриття News | Public Content Analytics |
Прибрати повтори з аналітичної метрики | Bounded analytics deduplication |
Знати, що News справді прочитана | Окремий read contract, тільки якщо продукт його чітко визначить |
Висновок: privacy теж є частиною продуктової архітектури
У цьому кейсі мені подобається, що маленька вимога до News-картки змушує чітко розділити кілька відповідальностей.
seen потрібен продукту для персоналізації відомого користувача. view потрібен Analytics для вимірювання. read має з’являтися тільки тоді, коли продукт справді може визначити, що це означає.
А для гостя не обов’язково створювати нову приховану identity лише тому, що персоналізований алгоритм міг би використати ще один сигнал.
Іноді правильна архітектура персоналізації полягає не в тому, щоб зібрати більше state, а в тому, щоб свідомо не створювати його там, де він не потрібен.



