GET /api/v1/dev-stats/duration/histogram — Гистограмма длительностей просмотра
Назначение
Распределение просмотров объектов застройщика по длительности — «как долго» смотрят, в виде бинов
(0–10 с / 10–30 с / 30–60 с / 1–3 мин / 3–5 мин / 5–10 мин / 10 мин+) с числом и долей просмотров в каждом,
плюс средняя и медианная длительность. Показывает глубину интереса (беглый скан vs вдумчивое изучение).
В отличие от сводок, гистограмме нужно распределение сырых длительностей, которого нет в rollup-мерах
(там только сумма/среднее) — поэтому метод читает сырьё property_object_view_facts,
склеивая open/closed-строки одного просмотра по PropertyObjectViewId (argMax по IngestionTime).
| Поле | Значение |
|---|
| Сервис/контекст | dev-stats |
| API area | developers |
| Feature group | duration |
| Статус | draft |
| Документ | docs/06-services/dev-stats/api/developers/get-duration-histogram.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT: пользователь застройщика) |
| Auth policy / Permissions | permission dev-stats.view (identity/domain/permission.md) |
| Scope (RBAC) | организация застройщика: scope строго = свой DeveloperId |
| Record-level | не применяется |
| Tenant isolation | TenantId = DeveloperId; фильтр DeveloperId обязателен и = актор (fail-closed, из identity) |
| Audit | none (агрегат-распределение без PII) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/dev-stats/duration/histogram |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Общий вход — DevStatsFilterModel. DeveloperId — из identity-claims актора.
Границы бинов каноничны (фиксированы), опционально переопределяются возрастающим массивом секунд.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
DeveloperId | identity claims | Guid | Да | != Guid.Empty; = актор (иначе 403) | Tenant isolation, fail-closed |
DateFrom | query dateFrom | DateOnly | Да | ISO yyyy-MM-dd | — |
DateTo | query dateTo | DateOnly | Да | ISO; DateFrom <= DateTo; окно ≤ 366 дней (сырьё — cost-guard) | — |
ProjectId…PropertyObjectId | query | Guid? | Нет | уровни иерархии (сверху вниз) | — |
ViewerKind | query viewerKind | ViewerKind? | Нет | wire guest/agent/investor; unknown → ValidationError | — |
ViewSource | query viewSource | ViewSource? | Нет | wire значения ViewSource | — |
BinEdgesSeconds | query binEdges | IReadOnlyList<int>? | Нет | строго возрастающие, ≥ 0, ≤ 12 границ; default [0,10,30,60,180,300,600] | — |
Модель ответа DurationHistogramResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
developerId | Guid | Да | echo актора | — | Застройщик (= scope). |
dateFrom / dateTo | string (ISO date) | Да | echo | — | Применённый период. |
totalViews | long | Да | count() схлопнутых PropertyObjectViewId | — | Всего просмотров в выборке (дедуп по PropertyObjectViewId). |
avgDurationSeconds | int | Да | sum(dur) / count() (guard 0) | — | Средняя длительность, сек. |
medianDurationSeconds | int | Да | quantileExact(0.5)(dur) | — | Медианная длительность, сек. |
bins | IReadOnlyList<DurationBinModel> | Да | GROUP BY bucket | агрегат | Бины по возрастанию границы. |
DurationBinModel
| Поле | Тип | Источник | Описание |
|---|
fromSeconds | int | нижняя граница бина (roundDown) | Включительно. |
toSeconds | int? | следующая граница | Исключительно; null — последний открытый бин (fromSeconds+). |
label | string | вычислено | Человекочитаемая метка (напр. 1–3 мин, 10 мин+). |
views | long | count() в бине | Число просмотров. |
share | decimal | views / totalViews (guard 0) | Доля бина, 0..1 (2 знака). |
Предусловия и согласованность данных
- Пользователь застройщика авторизован; есть permission
dev-stats.view; scope DeveloperId = актор
(INV-STA-1, fail-closed — ../../README.md).
- Метод читает сырьё
property_object_view_facts (в rollup распределения длительностей нет), поэтому окно
ограничено 366 днями (cost-guard).
- Длительность просмотра разрешается dedup-устойчиво: closed-строка (
property-object-view-ended, реальная
длительность) побеждает open-строку (dur=0) по IngestionTime; порядок доставки не важен, at-least-once
дубли схлопываются по PropertyObjectViewId (INV-STA-3). Просмотр с одним лишь open-фактом даёт dur=0 и
попадает в первый бин.
- Окно свежести событие→факт ≤ 5 мин (INV-STA-5); операция read-only (состояние не меняется, событий нет).
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом
dev-stats.view. DeveloperId берётся из identity-claims актора; если клиент прислал
DeveloperId ≠ актор → отказ DEV_STATS_FORBIDDEN (403).
- Проверка входа. Период задан и корректен, окно ≤ 366 дней (чтение сырья дороже rollup);
BinEdgesSeconds строго возрастает, ≥ 0, ≤ 12 значений; enum ViewerKind/ViewSource — из допустимого
множества (неизвестное → ValidationError); иерархия «сверху вниз».
- Что читаем. Гистограмме нужно распределение сырых длительностей, которого нет в rollup-мерах (там
только сумма/среднее), поэтому читаем факты
property_object_view_facts в два уровня, dedup-tolerant
без FINAL:
- схлопывание просмотра:
GROUP BY PropertyObjectViewId → argMax(DurationSeconds, IngestionTime) —
closed-строка (позже по IngestionTime, реальная длительность) побеждает open-строку (0); один
просмотр = одна длительность;
- бинирование:
roundDown(dur, [binEdges]), GROUP BY binLow ORDER BY binLow, count(); параллельно
count(), sum(dur), quantileExact(0.5)(dur) для total/avg/median.
Заданные уровни иерархии — префикс-скан по ключу (DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, ViewedAt, FactId); диапазон дат режет партиции toYYYYMM(ViewedAt).
- Расчёты и маппинг. Для каждой границы
binEdges[i] формируется DurationBinModel:
fromSeconds = binEdges[i], toSeconds = binEdges[i+1] (или null для последнего), label
человекочитаемая (сек/мин), views из группы (0, если бин пуст), share = views / totalViews.
avgDurationSeconds = sum(dur) / count (guard 0), medianDurationSeconds = quantileExact(0.5). Пустая
выборка → totalViews = 0, бины с нулями (стабильная форма ответа).
- Результат. Возвращается
DurationHistogramResult (200). SLO чтения сырья — p95 ≤ 600 мс.
Недоступность ClickHouse → DEV_STATS_UNAVAILABLE (503).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Нет/некорректный период, окно > 366 дней, невалидные binEdges, неизвестный enum, иерархия «снизу вверх» | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет JWT | Требуется авторизация | no retry |
DEV_STATS_FORBIDDEN | 403 | Нет dev-stats.view или DeveloperId ≠ актор | Доступ к статистике застройщика запрещён | no retry |
DEV_STATS_UNAVAILABLE | 503 | ClickHouse недоступен / timeout | Статистика временно недоступна | retry с backoff |
Пустая выборка — не ошибка: 200 с totalViews = 0 и нулевыми бинами. SLO чтения сырья — p95 ≤ 600 мс
(строже окно, чем у rollup-методов).
Acceptance Criteria
- Распределение по дефолтным бинам. Дано авторизованный застройщик и корректный период. Когда
GET /duration/histogram без binEdges. Тогда 200 с 7 бинами [0,10,30,60,180,300,600] (последний
открытый 10 мин+), суммой share ≈ 1, а также avgDurationSeconds/medianDurationSeconds.
- Схлопывание open/closed. Дано один просмотр имеет open-строку (
dur=0) и closed-строку (dur=95).
Когда строится гистограмма. Тогда просмотр учтён один раз с dur=95 (INV-STA-3), а не дважды.
- Только open-факт. Дано просмотр без closed-строки. Когда гистограмма. Тогда
dur=0, просмотр попадает
в первый бин (0–10 с).
- Кастомные границы. Дано
binEdges=[0,30,120]. Когда запрос. Тогда 3 бина (0–30, 30–120, 120+).
- Невалидные границы. Дано
binEdges=[30,10] (не по возрастанию) либо > 12 значений. Когда запрос.
Тогда 400 ValidationError.
- Пустая выборка. Дано период без просмотров. Когда запрос. Тогда
200, totalViews = 0, бины с
нулями (стабильная форма ответа).
- Guard доли. Дано
totalViews = 0. Когда считаются share/avg. Тогда = 0 без ошибки.
- Слишком длинное окно. Дано окно > 366 дней. Когда запрос. Тогда
400 ValidationError (cost-guard
сырья).
- Чужой scope / нет права. Дано чужой
DeveloperId или нет dev-stats.view. Когда запрос. Тогда
403 DEV_STATS_FORBIDDEN (INV-STA-1).
- Неизвестный enum. Дано
viewerKind=vip. Когда запрос. Тогда 400 ValidationError.
- Недоступность витрины. Дано ClickHouse недоступен. Когда запрос. Тогда
503 DEV_STATS_UNAVAILABLE
(retry с backoff).
Совместимость и наблюдаемость
- Optional-поля — additive; смена дефолтных
binEdges — семантически breaking для клиента-визуализатора
(согласовывать). Unknown enum → 400.
- Logs:
dev-stats-duration-histogram-read (developerId, dateFrom/dateTo, totalViews, binsCount, elapsed;
PII нет). Metrics: dev_stats_duration_histogram_requests_total, latency histogram (SLO p95 ≤ 600 мс —
сырьё). Traces: span приёма → span обработки → span чтения ClickHouse; correlation — сквозной trace
traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы