Consumer: DevStatsActionTrackedConsumer
Пишущий процесс dev-stats, разбирающий ленту действий каталога по дискриминатору ActionType: ViewPropertyObject →
open-факт просмотра в property_object_view_facts (гарантия строки при прямых заходах,
пока не пришёл closed property-object-view-ended); ApplyFilter/Search → вход воронки каталога (верхний этап
funnel_daily, предшествующий просмотрам). Остальные типы
(AddToFavorites/RemoveFromFavorites/AddToSelection) — пропускаются: этапы воронки favorites/
selections наполняются доменными событиями favorites/selections через отдельные консюмеры (иначе
двойной счёт). ADR-0057, ADR-0056.
Назначение
Consumer DevStatsActionTrackedConsumer слушает событие action-tracked (топик
pin.user-activity.action-tracked.v1 — один топик, единая лента действий; фильтрация по ActionType — на
стороне консюмера) и добавляет строки в ClickHouse pin_dev_stats батчами:
ViewPropertyObject→ open-строкаproperty_object_view_facts(DurationSeconds = 0,LastActivityAt = OccurredAt), тот жеPropertyObjectViewId, что у будущего closed-факта; обогащение иерархией изdim_property_object. При чтении open/closed склеиваются поPropertyObjectViewIdчерезargMax(*, IngestionTime)— реальная длительность из closed побеждает (см. «Deduplication» property-object-view-facts.md); если closed так и не пришёл (прямой заход, брошенная вкладка) — просмотр посчитан по open-строке.ApplyFilter/Search→ catalog-entry воронки: верхfunnel_dailyдо этапаViews. Действие несёт не объект недвижимости, аFilterSnapshot/SearchQuery; атрибуция застройщику — поDeveloperId/ProjectIdизFilterSnapshot(см. «Атрибуция catalog-entry»).
Owned-состояния в PostgreSQL консюмер не меняет: пишет только в ClickHouse.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | dev-stats (проект DevStats.Background) |
| Consumer | DevStatsActionTrackedConsumer |
| Обрабатываемое событие | action-tracked |
| Topic | pin.user-activity.action-tracked.v1 |
| ContractVersion | 1; при несовпадении — warn-and-proceed |
| Consumer group | dev-stats-action-tracked |
| Producer | user-activity — модуль actions (track-API ApplyFilter/Search; создание PropertyObjectView → ViewPropertyObject; консюмеры favorites/selections — не потребляются здесь) |
| Эффект (событие → что меняется) | ViewPropertyObject → open-факт property_object_view_facts; ApplyFilter/Search → catalog-entry funnel_daily |
| Feature flag | dev-stats.funnel.enabled — путь ApplyFilter/Search (catalog-entry) активируется вместе с воронкой; путь ViewPropertyObject работает всегда |
Контекст события
- Из метаданных события доступны идентификатор источника (
AggregateId = ActionEventId), тип агрегата (ActionEvent), время события (CreatedAt), версия; ключ партиционирования топика —AnonymousId. - Сквозной трейс восстанавливается из метаданных события; идентификатор сообщения (
MessageId) →SourceEventId(для open-фактаViewPropertyObject) / бизнес-ключ этапа (для catalog-entry). - Бизнес-идемпотентность — декларативная, на стороне ClickHouse: у open-факта
FactId = SourceEventId— хвостORDER BYReplacingMergeTree(повтор схлопывается при merge); у catalog-entry —uniqStateпо бизнес-ключу (ActionEventId) вAggregatingMergeTree(повтор не растит счётчик). Дополнительно — in-batch dedup поSourceEventId. Отдельная таблица идемпотентности не используется.
Входные данные / payload события
Контракт события: event-contract: action-tracked. Payload —
базовые поля + типизированный полиморфный Payload (ActionPayload) по ActionType.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
ActionEventId | Guid | Да | бизнес-ключ идемпотентности (catalog-entry uniqState) |
SessionId | Guid | Да | → SessionId open-факта |
AnonymousId | Guid | Да | → AnonymousId open-факта (вход uniqViewers) |
UserId | Guid? | Нет | → UserId (NULL — гость); вход классификации ViewerKind |
ActionType | string (enum) | Да | роутинг: ViewPropertyObject / ApplyFilter / Search / прочее (skip) |
TargetId | Guid? | Нет | объект недвижимости для ViewPropertyObject (= Payload.PropertyObjectId); ключ обогащения dim_property_object |
OccurredAt | DateTimeOffset | Да | → ViewedAt/EventTime/LastActivityAt open-факта; EventDate = toDate для catalog-entry |
Payload.PropertyObjectId (ViewPropertyObjectActionPayload) | Guid | Да (ViewPropertyObject) | → PropertyObjectId; Guid.Empty → poison |
Payload.PropertyObjectViewId (ViewPropertyObjectActionPayload) | Guid | Да (ViewPropertyObject) | → PropertyObjectViewId (склейка с closed-фактом) |
Payload.Filter (ApplyFilterActionPayload) | AppliedFilterModel | Да (ApplyFilter) | источник DeveloperId/ProjectId для атрибуции catalog-entry |
Payload.SearchQuery (SearchActionPayload) | string | Да (Search) | catalog-entry без объекта недвижимости; в факт/лог не разворачивается (PII-гигиена) |
Колонки open-факта, которые консюмер заполняет сам: FactId = SourceEventId (идентификатор сообщения);
IngestionTime = момент вставки; TenantId = DeveloperId (из dim_property_object); BrandId = NULL; иерархия — из dim_property_object;
ViewerKind — из UserId + роль; DurationSeconds = 0; LastActivityAt = OccurredAt.
ViewSource open-факта (зависимость от контракта / assumption)
ViewPropertyObjectActionPayload в v1 не несёт ViewSource (он есть только в property-object-view-ended), поэтому open-факт
пишет ViewSource = Unknown. Авторитетное значение прилетает с closed property-object-view-ended (тот же PropertyObjectViewId,
позже по IngestionTime) и побеждает при argMax — метрика по источнику остаётся корректной, если просмотр
закрылся. Чтобы «захват ViewSource» работал и для незакрытых просмотров, producer добавляет ViewSource в
ViewPropertyObjectActionPayload (additive-only, bump ContractVersion не нужен). Зафиксировано в «Известных
ограничениях» README.
Классификация ViewerKind
Идентична property-object-view-ended: UserId == null → Guest; аутентифицированный — Investor
(v1-fallback) с детекцией Agent по мере появления сигнала роли (нет dim_user в v1, ADR-0056).
Атрибуция catalog-entry (ApplyFilter/Search)
Действие каталога не несёт объекта недвижимости, а funnel_daily ключуется иерархией с PropertyObjectId. v1 (при
dev-stats.funnel.enabled): DeveloperId/ProjectId берутся из фасетов FilterSnapshot
(ApplyFilter); нижние уровни иерархии — zero-sentinel (00000000-…, грань «каталог/агрегат»),
EventDate = toDate(OccurredAt). Действие атрибутируется только если из фильтра резолвится DeveloperId
— иначе (свободный Search без застройщика в фасете) в развёрнутую по застройщику воронку не пишется
(нельзя атрибутировать конкретному застройщику без кросс-tenant утечки); учитывается лишь глобальным
ops-счётчиком. Мера catalog-entry — этап воронки, предшествующий Views; конкретная колонка принадлежит
funnel_daily и является additive-расширением её схемы (текущая схема перечисляет этапы от Views) —
зафиксировано как согласуемое расхождение в funnel-daily-rollup.md и
«Известных ограничениях» README (доки не расходятся молча).
Алгоритм
Консюмер батчевый: шаги 1–4 — на сообщение, шаги 5–6 — на батч (flush ≤ 1000 строк или 5 секунд, freshness SLA ≤ 5 мин).
- Приём входа и контекста. Восстановить трейс и контекст из метаданных события; сверить
ContractVersion(=1; при несовпадении — warn-and-proceed). In-batch dedup поSourceEventId. - Роутинг и валидация. По
ActionType:ViewPropertyObject→ ветка open-факта;ApplyFilter/Search→ ветка catalog-entry (только приdev-stats.funnel.enabled);AddToFavorites/RemoveFromFavorites/AddToSelectionи unknown-enum → skip (ack, без записи — обрабатываютсяfavorites/selections/crm; unknown — warning, телоPayloadне десериализуется). Валидация веткиViewPropertyObject: ошибка десериализации → DLQ;PropertyObjectId/PropertyObjectViewId == Guid.Empty→ DLQ;OccurredAt == default/> now + 1 ч → DLQ. - Обогащение (только
ViewPropertyObject). Batch-lookupdim_property_objectпоPropertyObjectId(argMax(…, UpsertedAt), безFINAL). Late-dimension: строки нет → park + deferred re-enrich (backoff, ≤ N попыток); устойчивый промах → DLQ + алерт «dimension gap». Для catalog-entry обогащения нет (иерархия — изFilterSnapshot). Мутаций PG нет. - Бизнес-правила и маппинг.
ViewPropertyObject→ строка open-фактаproperty_object_view_facts(DurationSeconds = 0,LastActivityAt = OccurredAt,ViewSource = Unknown, иерархия изdim_property_object,ViewerKind,FactId = SourceEventId).ApplyFilter/Search→ частичная строкаfunnel_daily:uniqState(ActionEventId)catalog-entry по (DeveloperId[,ProjectId], zero-sentinel,EventDate). Добавить в соответствующий буфер батча. - Пакетная вставка. По достижении порога — вставка буферов: open-факты в
pin_dev_stats.property_object_view_facts, catalog-entry вpin_dev_stats.funnel_daily. Точечное обновление/удаление/upsert запрещены — append-only / merge-состояния. Смещение (offset) фиксируется строго после подтверждённой вставки батча; сбой между вставкой и commit → повторная доставка → dedup (ReplacingMergeTreeдля open-факта;uniqStateдля catalog-entry) без видимых дублей. - Результат. Успех → ack/commit offset; метрики/логи; ошибка вставки → retry батча (идемпотентно), offset не двигается.
Диаграмма
Идемпотентность и replay
ViewPropertyObject:FactId = SourceEventId— dedupReplacingMergeTree; open/closed склеиваются поPropertyObjectViewId(argMax(*, IngestionTime)), closed с реальной длительностью побеждает.ApplyFilter/Search:uniqState(ActionEventId)— повтор доставки не растит catalog-entry (идемпотентно к at-least-once).- Replay: да — переигрывание топика группой
dev-stats-action-trackedс произвольного offset безопасно. - Пересечение с backfill исключено схемой ключей: backfill open-фактов использует детерминированный
SourceEventId, консюмер — идентификатор сообщения события.
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| ClickHouse недоступен / timeout вставки | Да: батч с backoff (1с → 60с), offset не commit-ится | Нет (сбой инфраструктуры) | Алерт по ingest-lag > 15 мин; восстановить CH — консюмер догонит |
dim_property_object не найден для ViewPropertyObject (late-dimension) | Да: park + deferred re-enrich (≤ N попыток) | Да при устойчивом промахе + алерт «dimension gap» | Backfill dim_property_object, затем replay из DLQ |
Ошибка десериализации / невалидный ViewPropertyObject payload | Нет | Да (poison, per-message) + error-лог | Проверить контракт producer-а user-activity; replay после исправления |
Версия события ≠ ContractVersion / unknown ActionType | Не сбой: warning, продолжить/skip | Нет | Сверить контракт; новый ActionType — additive |
| Сбой между вставкой батча и commit offset | Повторная доставка (at-least-once) | Нет | Не требуется — dedup CH |
Наблюдаемость и безопасность
- Logs: consumed (offset-диапазон, размер батча), routed (по
ActionType: view-property-object/catalog-entry/skipped), parked (late-dimension), skipped-poison, flushed (строк/таблица/длительность), failed.SearchQueryв логи не пишется (только длина). - Metrics:
dev_stats_action_view_property_object_ingested_total,dev_stats_action_catalog_entry_total,dev_stats_action_skipped_total{action_type},dev_stats_ingest_lag_seconds,dev_stats_dim_property_object_miss_total, счётчики retry/DLQ. - Traces: consume span + dim-lookup span (для
ViewPropertyObject) + insert span; корреляция из метаданных события;SourceEventIdсвязывает open-факт с событием. - PII/masking: факт несёт псевдонимные
UserId/AnonymousId/SessionId(Sensitive);SearchQueryв ClickHouse не пишется (свободный текст пользователя — остаётся вcrm-ленте лида);FilterSnapshotв факт не сохраняется (используются лишь id-фасеты для атрибуции). Наружу API отдаёт агрегаты. - Actor/permissions: пишет system-actor консюмера; прямой записи в CH извне нет. Tenant:
TenantId = DeveloperIdизdim_property_object(open-факт) / изFilterSnapshot(catalog-entry, только при резолве застройщика); scope застройщика применяется на чтении (DeveloperId = актор, fail-closed).
Кросс-сервисные контракты
| Направление | Контракт |
|---|---|
Ожидает от user-activity | Событие action-tracked (pin.user-activity.action-tracked.v1, ContractVersion = 1, at-least-once, ключ партиционирования — AnonymousId). Для ViewPropertyObject: Payload.PropertyObjectId/PropertyObjectViewId (тот же PropertyObjectViewId, что у будущего closed property-object-view-ended — гарантия склейки). Для ApplyFilter/Search: Payload.Filter/SearchQuery с id-фасетами застройщика. ViewSource в ViewPropertyObjectActionPayload в v1 отсутствует — additive-расширение (см. «ViewSource open-факта»). |
Ожидает от chessboard (через dim-ingest) | Строку dim_property_object для обогащения open-факта иерархией; при отставании — late-dimension (park → deferred re-enrich → DLQ + «dimension gap»). |
| Предоставляет | (1) Open-строку property_object_view_facts (FactId = SourceEventId, DurationSeconds = 0, ViewSource = Unknown) — склеивается с closed-фактом по PropertyObjectViewId (argMax(*, IngestionTime), closed побеждает). (2) Catalog-entry funnel_daily (этап-предшественник Views, uniqState(ActionEventId)) — только при резолвимом DeveloperId. Потребители: read API просмотров/воронки (argMax/uniqMerge, без FINAL). |
| Окно eventual-consistency | ≤ 5 мин. Незакрытый просмотр виден по open-факту с DurationSeconds = 0, пока не придёт closed; catalog-entry без резолва застройщика в объектную воронку не попадает (только ops-счётчик). |
Предусловия и постусловия
- Предусловия: для ветки
ViewPropertyObjectнаполнена строкаdim_property_object(иначе park); для веткиApplyFilter/Searchвключён флагdev-stats.funnel.enabledи из фасетов резолвитсяDeveloperId. - Постусловия:
ViewPropertyObject→ вproperty_object_view_factsесть open-строка сFactId = SourceEventId(гарантия строки при прямом заходе до closed-факта);ApplyFilter/Searchс резолвом застройщика → вfunnel_dailyучтён вход воронки (uniqState(ActionEventId), идемпотентно); прочиеActionType— skip; offset продвинут строго после подтверждённой вставки.
Acceptance Criteria
- Open-факт (гарантия строки): Дано —
dim_property_objectдляPO1есть; Когда — приходитViewPropertyObject(PropertyObjectViewId = V1,PO1); Тогда — в факте появляется open-строка (DurationSeconds = 0,ViewSource = Unknown), просмотр посчитан даже без closed-факта. - Склейка open/closed: Дано — есть open-строка
V1; Когда — позже приходит closedproperty-object-view-endedc тем жеV1и реальной длительностью; Тогда — при чтении (argMax(*, IngestionTime)) побеждает closed (реальнаяDurationSeconds, авторитетныйViewSource). - Catalog-entry с резолвом: Дано — флаг включён, из
FilterSnapshotрезолвитсяDeveloperId; Когда — приходитApplyFilter; Тогда — вход воронки учтёнuniqState(ActionEventId)по срезу застройщика. - Search без застройщика: Дано — свободный
Searchбез застройщика в фасете; Когда — обработка; Тогда — в объектную воронку не пишется (нет кросс-tenant утечки), учитывается лишь ops-счётчиком. - Пропускаемые типы: Дано —
AddToFavorites/AddToSelection/unknownActionType; Когда — обработка; Тогда — skip (ack, без двойного счёта — эти этапы наполняютfavorites/selections). - Late-dimension / отказ хранилища: Дано — нет
dim_property_objectили ClickHouse недоступен; Тогда — park/deferred re-enrich (при устойчивом промахе → DLQ + алерт) / offset не коммитится и retry с backoff.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Классификация зрителя и источника (ViewerKind / ViewSource)
- События (2): События dev-stats, Event Contract: pin.user-activity.action-tracked (ActionTrackedKafkaEvent)
- Консюмеры (2): Consumer: DevStatsPropertyObjectViewEndedConsumer, Consumer: DevStatsSessionEndedConsumer
- Дашборды (1): Ops-борд: dev-stats-ingest-overview (здоровье ingestion)
- index.md (1): DevStats Service (Pin.DevStats)
- facts (1): PropertyObjectViewFact (ClickHouse)
Связанные документы
- Эталон CH-консюмера: invest-analytics/consumers/chessboard-price-changed.md.
- Контракт события: ../../user-activity/events/action-tracked.md.
- Дом open-факта: property-object-view-facts.md; воронка (catalog-entry, стадии): funnel-daily-rollup.md; измерение обогащения: ../dimensions/property-object.md; closed-путь просмотра: property-object-view-ended.md.
- Границы контекста: ../README.md. ADR-0057 (dev-stats, разграничение с воронкой каталога), ADR-0056 (simplicity-first), ADR-0052 (tenancy).