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 area | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT sub) |
| Feature group | sessions |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/session-heartbeat.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (гость, заголовок X-Anonymous-Id) или User (Инвестор с JWT, claim sub) |
| Auth policy / Permissions | none (публичная self-scope команда); rate limit по AnonymousId+IP (высокочастотный вызов) |
| Scope (RBAC) | не применяется (актор вне tenant, ADR-0052 п.2) |
| Record-level | self: сессия принадлежит вызывающему устройству — AnonymousId цели = X-Anonymous-Id (в предикате Update, шаг 5) |
| Tenant isolation | вне tenant (ADR-0052 п.2): tenant-интерсепторы не подключаются; изоляция record-level по AnonymousId |
| Audit | none — LastActivityAt доменное поле жизненного цикла, не audit; per-call не логируется (частота, SLO) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/user-activity/sessions/heartbeat |
| Success status | 200 — как продление, так и авто-рестарт (различаются флагом Restarted, не HTTP-кодом) |
| Idempotency header | не применяется — операция естественно идемпотентна (LastActivityAt = now() перезаписывается) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
Клиентские timestamp не принимаются — LastActivityAt ставит только сервер (now(), INV-SES-004);
контракт таких полей не содержит, лишние отвергаются валидатором.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
AnonymousId | header X-Anonymous-Id | Guid | Да | валидный непустой Guid; ключ владения устройством | device identity (не PII) |
SessionId | body | Guid | Да | != Guid.Empty; цель продления (Session.Id, выдан start-session) | — |
ActorUserId | identity claims (JWT sub) | Guid? | Нет | присутствует, если пользователь авторизован; при авто-рестарте фиксируется в новой сессии | — |
UserAgent | header User-Agent | string? | Нет | ≤ 1024; используется только в ветке авто-рестарта для парсинга DeviceInfo | — |
IpAddress | connection / X-Forwarded-For | string? | Нет | IPv4/IPv6; только для DeviceInfo при авто-рестарте | квази-PII: в логах маска до /24, из ответов исключён |
Модель ответа SessionHeartbeatResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
SessionId | Guid | Да | Session.Id актуальной Active-сессии | — | Совпадает со входным при продлении; новый id при авто-рестарте — клиент обязан его сохранить. |
Status | SessionStatus (enum строкой) | Да | Session.Status | — | Всегда Active (успех heartbeat означает открытую сессию). |
LastActivityAt | DateTimeOffset | Да | серверное now() | — | Зафиксированная активность. |
Restarted | bool | Да | computed (шаг 4) | — | true — предыдущая сессия была закрыта, создана/переиспользована новая (клиент обновляет хранимый SessionId). |
Предусловия и постусловия
Предусловия:
- Клиент располагает
SessionId(выданstart-session) иX-Anonymous-Id. - Активность сессии на входе не гарантирована — метод покрывает и продление, и авто-рестарт закрытой (INV-SES-003).
Постусловия (гарантии после успеха):
- Существует
Active-сессия устройства сLastActivityAt = now: продлена та же (Restarted = false) либо создана новая (Restarted = true); еёSessionId— в ответе (при авто-рестарте клиент обязан заменить хранимый id). - Интеграционных событий не отправлено; при авто-рестарте поднято только доменное
SessionStartedEvent.
Алгоритм
- Контекст и доступ. Актор —
Anonymous(X-Anonymous-Id) илиUser(UserIdиз JWTsub), self-scope, вне tenant (ADR-0052 п.2). Ключ владения —AnonymousId. Идемпотентность естественная (повтор лишь двигаетLastActivityAt). - Проверка входа.
X-Anonymous-Idприсутствует и валидныйGuid;SessionId != Guid.Empty— иначеVALIDATION_FAILED(400); отсутствие/невалидностьX-Anonymous-Id→USER_ACTIVITY_ANONYMOUS_ID_REQUIRED(400). Существование сессии отдельным чтением не проверяется — отсутствие/закрытость выражается числом затронутых строк (0 → авто-рестарт, не ошибка, INV-SES-003). Допустимое состояние — толькоStatus = Active(закодировано в условии, шаг 3). Клиентские timestamp не принимаются (INV-SES-004). - Что меняется (основной путь). Продлить
LastActivityAt = now()(сервер, INV-SES-004) только для сессии с даннымSessionId, владельцемAnonymousIdи статусомActive(record-level в условии). ПереходActive → Active. Затронута одна строка →Restarted = false. - Ветка авто-рестарта (0 затронутых строк, INV-SES-003). Активной сессии нет (закрыта worker-ом или
форс-закрыта при смене пользователя) — создаётся новая
Active-сессия:AnonymousIdиз заголовка,Status = Active,CreatedAt = LastActivityAt = now(),Device— снимок изUser-Agent/IP; если акторUser— рождается связанной (UserId,LinkedAt = now(), единая история сохраняется сразу), иначеnull. Поднимается доменное событиеSessionStartedEvent. Гонка partial-uniqueux_sessions_active_anonymous(двойной heartbeat/start-session, INV-SES-001) → перечитать активную сессию устройства и продлить её (Restarted = true, её id). Если перечитка ничего не находит (крайне редко) →USER_ACTIVITY_SESSION_ACTIVE_RACE(409, повторить). - Согласованность и события. Основной путь — одно условное изменение, событий нет. Авто-рестарт —
вставка новой сессии; доменное
SessionStartedEventфиксируется вместе с ней. Kafka-события у старта нет (session-startedв контексте не публикуется — см. produced-список README);SessionLinkedKafkaEventпри set-at-creationUserIdне эмитится (этот контракт описывает переходnull → valueна существующей гостевой сессии, INV-SES-002, а не создание уже связанной). - Результат.
{ SessionId (актуальной Active), Status = Active, LastActivityAt = now(), Restarted }→200. При авто-рестартеSessionId— новый; клиент обязан его сохранить.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (продление); авто-рестарт — только доменное SessionStartedEvent (in-memory), Kafka нет |
| External calls | none |
| Cache / projections / search | none |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | SessionId пуст или прислан клиентский timestamp/лишнее поле | «Проверьте параметры запроса» | no retry |
USER_ACTIVITY_ANONYMOUS_ID_REQUIRED | 400 | отсутствует/невалиден заголовок X-Anonymous-Id | «Не удалось определить устройство» | no retry (получить id через start-session) |
USER_ACTIVITY_SESSION_ACTIVE_RACE | 409 | гонка 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-сессия устройства существует, WhenPOST /sessions/heartbeatс еёSessionId, Then200,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, ThenUSER_ACTIVITY_SESSION_ACTIVE_RACE(409), клиент повторяет (идемпотентно). - Given
SessionId = Guid.Emptyили прислано лишнее/timestamp-поле, When heartbeat, ThenVALIDATION_FAILED(400). - Given отсутствует/невалиден
X-Anonymous-Id, When heartbeat, ThenUSER_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 Grafanapin-user-activity. Alert: аномально высокая доляrestarted— сигнал рассинхрона порога неактивностиactivity-closer(runbookdocs/06-services/user-activity/runbooks/, будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-ACT-001. Трекинг сессии гостя и пользователя, UC-ACT-005. Слияние гостевой и пользовательской активности при входе
- registries (1): Реестр API-методов PIN
Связанные документы
- Дом сущности: 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).