Skip to content

Player Activity Tracking

The player-activity layer is dual-layer: Redis holds hot, TTL-expiring session/event/summary data for online presence and live counters; selected session-boundary and trade events also write durable Postgres rows (PlayerSession / PlayerActivity) that retention and region-activity jobs read.

Implemented in services/gameserver/src/services/player_activity_service.py (PlayerActivityService), reached through the module-level singleton accessor get_player_activity_service(). Each method lazily resolves the shared RedisService. Durable mirrors accept an optional sync SQLAlchemy Session (db=) because production callers (auth.py, trading.py) already hold sync sessions from get_db.

What is tracked

Two kinds of writes flow through the service:

  • Session lifecycletrack_login opens a session; track_logout finalises it, computing duration_seconds and folding the session into the rolling and daily summaries. Login and logout are wired into the auth routes (services/gameserver/src/api/routes/auth.py), mapping the authenticated user to its Player row; admins and non-player users are skipped, and any tracking failure is swallowed so it can never break auth. When db is passed (auth does), login/logout also mirror into Postgres (below).
  • Gameplay eventstrack_activity(player_id, event_type, details, db=None) increments the live session counters and appends an event record. The recognised event types (ActivityEventType) are login, logout, trade_buy, trade_sell, combat_attack, combat_defend, sector_move, dock, undock, planet_land, and warp. Trade events add to trades_count and trade_volume (from details["total_value"]); combat events add to combat_events; sector_move appends to a deduplicated sectors_visited list capped at the last 100 entries. When db is passed for trade_buy / trade_sell (trading routes do), those events also insert durable PlayerActivity rows.

Every write also pushes an individual event onto the player's Redis event list (_record_event), trimmed to the most recent 500 entries.

Redis keys and TTLs

Key Contents TTL
activity:session:{player_id} Current session: login/last-activity timestamps and live counters 24 hours
activity:events:{player_id} Most recent events (list, capped at 500) 7 days
activity:summary:{player_id} Rolling per-player summary: total sessions, playtime, actions, trades, trade volume, combat events, unique sectors 30 days
activity:daily:{player_id}:{date} Per-day aggregates (UTC date) 14 days
activity:online_players Set of currently-online player IDs no TTL (managed by add on login / remove on logout)

The session key is added to activity:online_players on login and removed on logout; get_online_player_count and get_online_player_ids read that set. The session key itself is deleted on logout.

Persistence — dual-layer (Redis hot + durable SQL)

Redis hot path

Session counters, event lists, rolling/daily summaries, and the online-player set live in Redis and are not durable. Counters and summaries are written with SETEX (RedisService.cache_set), so they expire on the TTLs above and are lost on a Redis flush or data loss.

Consequences for the Redis layer:

  • The online-player set, session counters, summaries, and per-day aggregates reset to zero on Redis loss.
  • The longest-lived Redis record is the 30-day rolling summary; per-day aggregates persist 14 days, raw events 7 days, and the live session 24 hours.
  • Read accessors (get_player_session, get_player_summary, get_recent_events, get_daily_stats) return zeroed defaults when the corresponding key has expired or never existed, rather than erroring.

Durable Postgres writebacks

When callers pass a sync db session, the service also mirrors into tables defined in services/gameserver/src/models/player_analytics.py (best-effort / non-fatal — failures are logged and rolled back or savepoint-isolated so they cannot break auth or trade):

Path Durable write Notes
track_login(..., db=) Updates Player.last_game_login; opens PlayerSession; inserts PlayerActivity type login; stashes db_session_id on the Redis session dict Wired from auth.py
track_logout(..., db=) Completes matching PlayerSession (end_time, duration_minutes, actions/sectors); inserts PlayerActivity type logout Requires db_session_id from login; wired from auth.py
track_activity(..., trade_buy\|trade_sell, db=) Inserts PlayerActivity with credits_involved (and optional items_involved) Wired from trading.py; uses a savepoint so a failed insert cannot poison the trade transaction (WO-BUILD-RETENTION-SIGNALS-TRADE-SQL-INSERT / e42d6c6e)

These durable rows feed RetentionService at-risk signals (declining_session_length, early_logout_streak, economic_loss_streak) and the WO-G18 Region.active_players_30d recompute. See OPERATIONS/retention.md.

There is no separate async “drain Redis → Postgres every N minutes” job on this path today — durability is the synchronous db= writebacks above.

Event types that remain Redis-only

These ActivityEventType values update Redis session counters / event lists only; they do not insert PlayerActivity rows even when db is available:

  • combat_attack
  • combat_defend
  • sector_move
  • dock
  • undock
  • planet_land
  • warp

trade_buy / trade_sell called without db are also Redis-only for that call. Residual undercount for region/retention aggregates: players whose only activity in the window is one of the Redis-only types above, or durable rows with sector_id NULL (they fail the region join).

Source map

Concern Path
Activity service services/gameserver/src/services/player_activity_service.py
Redis cache primitives (cache_set/cache_get/cache_delete) services/gameserver/src/services/redis_service.py
Login / logout wiring services/gameserver/src/api/routes/auth.py
Trade wiring (trade_buy / trade_sell + db=) services/gameserver/src/api/routes/trading.py
Durable models services/gameserver/src/models/player_analytics.py (PlayerSession, PlayerActivity, PlayerAnalyticsSnapshot)
Durable last-login column services/gameserver/src/models/player.py (Player.last_game_login)