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

POST /api/user-activity/sessions/heartbeat — Heartbeat активности сессии

Дешёвый heartbeat активности устройства: продлевает LastActivityAt открытой (Active) сессии, не загружая агрегат, одним условным изменением (ADR-0056 п.3, целевой SLO трекинг-команд p95 < 200 мс). Если активной сессии нет (закрыта worker-ом activity-closer по неактивности либо форс-закрыта при смене пользователя) — heartbeat не ошибка, а сигнал «устройство вернулось»: сессия авто-рестартится (INV-SES-003). Фронт вызывает метод раз в ~60 с и на любое track-действие. Дом полей — domain/session.md.

Назначение

Продлить период активности устройства/браузера без чтения агрегата: перевести LastActivityAt в серверное now() для сессии, оставшейся Active, либо пересоздать сессию, если предыдущая уже закрыта. Owning-сервис user-activity, модуль sessions (ADR-0054).

Metadata

ПолеЗначение
Сервис/контекстuser-activity
API areapublic (self-scope: гость по X-Anonymous-Id, пользователь по JWT sub)
Feature groupsessions
Статусdraft
Документdocs/06-services/user-activity/api/public/session-heartbeat.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (гость, заголовок X-Anonymous-Id) или User (Инвестор с JWT, claim sub)
Auth policy / Permissionsnone (публичная self-scope команда); rate limit по AnonymousId+IP (высокочастотный вызов)
Scope (RBAC)не применяется (актор вне tenant, ADR-0052 п.2)
Record-levelself: сессия принадлежит вызывающему устройству — AnonymousId цели = X-Anonymous-Id (в предикате Update, шаг 5)
Tenant isolationвне tenant (ADR-0052 п.2): tenant-интерсепторы не подключаются; изоляция record-level по AnonymousId
Auditnone — LastActivityAt доменное поле жизненного цикла, не audit; per-call не логируется (частота, SLO)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/user-activity/sessions/heartbeat
Success status200 — как продление, так и авто-рестарт (различаются флагом Restarted, не HTTP-кодом)
Idempotency headerне применяется — операция естественно идемпотентна (LastActivityAt = now() перезаписывается)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

Клиентские timestamp не принимаются — LastActivityAt ставит только сервер (now(), INV-SES-004); контракт таких полей не содержит, лишние отвергаются валидатором.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
AnonymousIdheader X-Anonymous-IdGuidДавалидный непустой Guid; ключ владения устройствомdevice identity (не PII)
SessionIdbodyGuidДа!= Guid.Empty; цель продления (Session.Id, выдан start-session)
ActorUserIdidentity claims (JWT sub)Guid?Нетприсутствует, если пользователь авторизован; при авто-рестарте фиксируется в новой сессии
UserAgentheader User-Agentstring?Нет≤ 1024; используется только в ветке авто-рестарта для парсинга DeviceInfo
IpAddressconnection / X-Forwarded-Forstring?НетIPv4/IPv6; только для DeviceInfo при авто-рестартеквази-PII: в логах маска до /24, из ответов исключён

Модель ответа SessionHeartbeatResult

ПолеТипОбязательностьИсточникMaskingОписание
SessionIdGuidДаSession.Id актуальной Active-сессииСовпадает со входным при продлении; новый id при авто-рестарте — клиент обязан его сохранить.
StatusSessionStatus (enum строкой)ДаSession.StatusВсегда Active (успех heartbeat означает открытую сессию).
LastActivityAtDateTimeOffsetДасерверное now()Зафиксированная активность.
RestartedboolДаcomputed (шаг 4)true — предыдущая сессия была закрыта, создана/переиспользована новая (клиент обновляет хранимый SessionId).

Предусловия и постусловия

Предусловия:

  • Клиент располагает SessionId (выдан start-session) и X-Anonymous-Id.
  • Активность сессии на входе не гарантирована — метод покрывает и продление, и авто-рестарт закрытой (INV-SES-003).

Постусловия (гарантии после успеха):

  • Существует Active-сессия устройства с LastActivityAt = now: продлена та же (Restarted = false) либо создана новая (Restarted = true); её SessionId — в ответе (при авто-рестарте клиент обязан заменить хранимый id).
  • Интеграционных событий не отправлено; при авто-рестарте поднято только доменное SessionStartedEvent.

Алгоритм

  1. Контекст и доступ. Актор — Anonymous (X-Anonymous-Id) или User (UserId из JWT sub), self-scope, вне tenant (ADR-0052 п.2). Ключ владения — AnonymousId. Идемпотентность естественная (повтор лишь двигает LastActivityAt).
  2. Проверка входа. X-Anonymous-Id присутствует и валидный Guid; SessionId != Guid.Empty — иначе VALIDATION_FAILED (400); отсутствие/невалидность X-Anonymous-IdUSER_ACTIVITY_ANONYMOUS_ID_REQUIRED (400). Существование сессии отдельным чтением не проверяется — отсутствие/закрытость выражается числом затронутых строк (0 → авто-рестарт, не ошибка, INV-SES-003). Допустимое состояние — только Status = Active (закодировано в условии, шаг 3). Клиентские timestamp не принимаются (INV-SES-004).
  3. Что меняется (основной путь). Продлить LastActivityAt = now() (сервер, INV-SES-004) только для сессии с данным SessionId, владельцем AnonymousId и статусом Active (record-level в условии). Переход Active → Active. Затронута одна строка → Restarted = false.
  4. Ветка авто-рестарта (0 затронутых строк, INV-SES-003). Активной сессии нет (закрыта worker-ом или форс-закрыта при смене пользователя) — создаётся новая Active-сессия: AnonymousId из заголовка, Status = Active, CreatedAt = LastActivityAt = now(), Device — снимок из User-Agent/IP; если актор User — рождается связанной (UserId, LinkedAt = now(), единая история сохраняется сразу), иначе null. Поднимается доменное событие SessionStartedEvent. Гонка partial-unique ux_sessions_active_anonymous (двойной heartbeat/start-session, INV-SES-001) → перечитать активную сессию устройства и продлить её (Restarted = true, её id). Если перечитка ничего не находит (крайне редко) → USER_ACTIVITY_SESSION_ACTIVE_RACE (409, повторить).
  5. Согласованность и события. Основной путь — одно условное изменение, событий нет. Авто-рестарт — вставка новой сессии; доменное SessionStartedEvent фиксируется вместе с ней. Kafka-события у старта нет (session-started в контексте не публикуется — см. produced-список README); SessionLinkedKafkaEvent при set-at-creation UserId не эмитится (этот контракт описывает переход null → value на существующей гостевой сессии, INV-SES-002, а не создание уже связанной).
  6. Результат. { SessionId (актуальной Active), Status = Active, LastActivityAt = now(), Restarted }200. При авто-рестарте SessionId — новый; клиент обязан его сохранить.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (продление); авто-рестарт — только доменное SessionStartedEvent (in-memory), Kafka нет
External callsnone
Cache / projections / searchnone
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400SessionId пуст или прислан клиентский timestamp/лишнее поле«Проверьте параметры запроса»no retry
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED400отсутствует/невалиден заголовок X-Anonymous-Id«Не удалось определить устройство»no retry (получить id через start-session)
USER_ACTIVITY_SESSION_ACTIVE_RACE409гонка ux_sessions_active_anonymous, и перечитка активной сессии ничего не нашла (крайне редко)«Повторите попытку»retry (idempotent)

Heartbeat по закрытой (Inactive) или отсутствующей сессии — не ошибка: авто-рестарт возвращает 200 с Restarted = true (INV-SES-003). Тем самым USER_ACTIVITY_SESSION_ALREADY_ENDED (INV-SES-006) для этого метода не применяется.

Acceptance-критерии

  • Given Active-сессия устройства существует, When POST /sessions/heartbeat с её SessionId, Then 200, Restarted = false, LastActivityAt продлён, событий нет.
  • Given сессия закрыта worker-ом или форс-закрыта при смене пользователя, When heartbeat, Then 200, Restarted = true, создана новая Active-сессия с новым SessionId (клиент его сохраняет), INV-SES-003.
  • Given авторизованный актор (JWT sub) при авто-рестарте, When heartbeat, Then новая сессия рождается связанной (UserId заполнен), SessionLinkedKafkaEvent не эмитится.
  • Given гонка авто-рестарта (двойной heartbeat/start-session) и перечитка активной сессии ничего не находит, When heartbeat, Then USER_ACTIVITY_SESSION_ACTIVE_RACE (409), клиент повторяет (идемпотентно).
  • Given SessionId = Guid.Empty или прислано лишнее/timestamp-поле, When heartbeat, Then VALIDATION_FAILED (400).
  • Given отсутствует/невалиден X-Anonymous-Id, When heartbeat, Then USER_ACTIVITY_ANONYMOUS_ID_REQUIRED (400).

Совместимость и наблюдаемость

  • Новые optional поля тела/заголовков — additive, без breaking change; удаление/переименование SessionId/X-Anonymous-Id или смена их типа — breaking (migration + rollout). Status возвращается строкой (Active); server-clock-only контракт неизменен.
  • Logs: per-call heartbeat не логируется (частота, SLO); лог только авто-рестарта (session auto-restarted: SessionId, AnonymousId; IP — маской /24). Metrics: user_activity_session_heartbeats_total{result=updated|restarted}, latency p95 < 200 мс (SLO README). Traces — сквозной trace через traceparent; dashboard Grafana pin-user-activity. Alert: аномально высокая доля restarted — сигнал рассинхрона порога неактивности activity-closer (runbook docs/06-services/user-activity/runbooks/, будущий путь по конвенции).

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

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

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

  • Дом сущности: domain/session.md (поля, INV-SES-001/003/004/006, DeviceInfo, SessionStatus, SessionStartedEvent).
  • Смежные операции модуля sessions: start-session.md (выдача AnonymousId + первичная Active), link.md (связывание UserId, INV-SES-002); закрытие — background/activity-closer.md (все будущие пути по конвенции, карта — README).
  • Решения: ADR-0052 tenancy (вне tenant), ADR-0054 (модуль sessions), ADR-0056 п.3 (heartbeat/закрытие без Redis).