Перейти к основному содержимому

Consumer: DevStatsActionTrackedConsumer

Пишущий процесс dev-stats, разбирающий ленту действий каталога по дискриминатору ActionType: ViewPropertyObjectopen-факт просмотра в 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)
ConsumerDevStatsActionTrackedConsumer
Обрабатываемое событиеaction-tracked
Topicpin.user-activity.action-tracked.v1
ContractVersion1; при несовпадении — warn-and-proceed
Consumer groupdev-stats-action-tracked
Produceruser-activity — модуль actions (track-API ApplyFilter/Search; создание PropertyObjectViewViewPropertyObject; консюмеры favorites/selections — не потребляются здесь)
Эффект (событие → что меняется)ViewPropertyObject → open-факт property_object_view_facts; ApplyFilter/Search → catalog-entry funnel_daily
Feature flagdev-stats.funnel.enabled — путь ApplyFilter/Search (catalog-entry) активируется вместе с воронкой; путь ViewPropertyObject работает всегда

Контекст события

  • Из метаданных события доступны идентификатор источника (AggregateId = ActionEventId), тип агрегата (ActionEvent), время события (CreatedAt), версия; ключ партиционирования топика — AnonymousId.
  • Сквозной трейс восстанавливается из метаданных события; идентификатор сообщения (MessageId) → SourceEventId (для open-факта ViewPropertyObject) / бизнес-ключ этапа (для catalog-entry).
  • Бизнес-идемпотентность — декларативная, на стороне ClickHouse: у open-факта FactId = SourceEventId — хвост ORDER BY ReplacingMergeTree (повтор схлопывается при merge); у catalog-entry — uniqState по бизнес-ключу (ActionEventId) в AggregatingMergeTree (повтор не растит счётчик). Дополнительно — in-batch dedup по SourceEventId. Отдельная таблица идемпотентности не используется.

Входные данные / payload события

Контракт события: event-contract: action-tracked. Payload — базовые поля + типизированный полиморфный Payload (ActionPayload) по ActionType.

Поле payloadТипОбязательностьКак используется (маппинг)
ActionEventIdGuidДабизнес-ключ идемпотентности (catalog-entry uniqState)
SessionIdGuidДаSessionId open-факта
AnonymousIdGuidДаAnonymousId open-факта (вход uniqViewers)
UserIdGuid?НетUserId (NULL — гость); вход классификации ViewerKind
ActionTypestring (enum)Дароутинг: ViewPropertyObject / ApplyFilter / Search / прочее (skip)
TargetIdGuid?Нетобъект недвижимости для ViewPropertyObject (= Payload.PropertyObjectId); ключ обогащения dim_property_object
OccurredAtDateTimeOffsetДа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 == nullGuest; аутентифицированный — 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 мин).

  1. Приём входа и контекста. Восстановить трейс и контекст из метаданных события; сверить ContractVersion (= 1; при несовпадении — warn-and-proceed). In-batch dedup по SourceEventId.
  2. Роутинг и валидация. По ActionType: ViewPropertyObject → ветка open-факта; ApplyFilter/Search → ветка catalog-entry (только при dev-stats.funnel.enabled); AddToFavorites/RemoveFromFavorites/ AddToSelection и unknown-enumskip (ack, без записи — обрабатываются favorites/selections/ crm; unknown — warning, тело Payload не десериализуется). Валидация ветки ViewPropertyObject: ошибка десериализации → DLQ; PropertyObjectId/PropertyObjectViewId == Guid.Empty → DLQ; OccurredAt == default/> now + 1 ч → DLQ.
  3. Обогащение (только ViewPropertyObject). Batch-lookup dim_property_object по PropertyObjectId (argMax(…, UpsertedAt), без FINAL). Late-dimension: строки нет → park + deferred re-enrich (backoff, ≤ N попыток); устойчивый промах → DLQ + алерт «dimension gap». Для catalog-entry обогащения нет (иерархия — из FilterSnapshot). Мутаций PG нет.
  4. Бизнес-правила и маппинг. 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). Добавить в соответствующий буфер батча.
  5. Пакетная вставка. По достижении порога — вставка буферов: 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) без видимых дублей.
  6. Результат. Успех → ack/commit offset; метрики/логи; ошибка вставки → retry батча (идемпотентно), offset не двигается.

Диаграмма

Идемпотентность и replay

  • ViewPropertyObject: FactId = SourceEventId — dedup ReplacingMergeTree; 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

СбойRetryDLQРучное действие
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; Когда — позже приходит closed property-object-view-ended c тем же V1 и реальной длительностью; Тогда — при чтении (argMax(*, IngestionTime)) побеждает closed (реальная DurationSeconds, авторитетный ViewSource).
  • Catalog-entry с резолвом: Дано — флаг включён, из FilterSnapshot резолвится DeveloperId; Когда — приходит ApplyFilter; Тогда — вход воронки учтён uniqState(ActionEventId) по срезу застройщика.
  • Search без застройщика: Дано — свободный Search без застройщика в фасете; Когда — обработка; Тогда — в объектную воронку не пишется (нет кросс-tenant утечки), учитывается лишь ops-счётчиком.
  • Пропускаемые типы: ДаноAddToFavorites/AddToSelection/unknown ActionType; Когда — обработка; Тогда — skip (ack, без двойного счёта — эти этапы наполняют favorites/selections).
  • Late-dimension / отказ хранилища: Дано — нет dim_property_object или ClickHouse недоступен; Тогда — park/deferred re-enrich (при устойчивом промахе → DLQ + алерт) / offset не коммитится и retry с backoff.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы