Export app, process and log telemetry when OTEL_ENABLED is set
Adds tavolo.active_users and tavolo.ongoing_games gauges, process/CPython metrics via opentelemetry-instrumentation-system-metrics and OTLP log export. The game store tracks ongoing matches (Redis sorted set, in-memory set) and the session stores expose live counts for the gauges; a poller refreshes them since metric callbacks cannot await the stores.
This commit is contained in:
15 files changed
+872
-31
No files matched your search
@@ -2,11 +2,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.platform.store import (
|
||||
ONGOING_GAMES_KEY,
|
||||
InMemoryGameStore,
|
||||
RedisGameStore,
|
||||
)
|
||||
from helpers import DummyEngine, async_test
|
||||
|
||||
|
||||
@@ -27,6 +32,81 @@ def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
|
||||
return session
|
||||
|
||||
|
||||
class _FakePipeline:
|
||||
"""Records commands and applies them to the fake Redis on execute."""
|
||||
|
||||
def __init__(self, redis: "_FakeRedis") -> None:
|
||||
self._redis = redis
|
||||
self._ops: list = []
|
||||
|
||||
async def __aenter__(self) -> "_FakePipeline":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: str, ex: object = None) -> None:
|
||||
self._ops.append(("set", key))
|
||||
|
||||
def zadd(self, key: str, mapping: dict) -> None:
|
||||
self._ops.append(("zadd", key, mapping))
|
||||
|
||||
def zrem(self, key: str, member: str) -> None:
|
||||
self._ops.append(("zrem", key, member))
|
||||
|
||||
def expire(self, key: str, ttl: int) -> None:
|
||||
self._ops.append(("expire", key))
|
||||
|
||||
def zremrangebyscore(self, key: str, minimum: object, maximum: object) -> None:
|
||||
self._ops.append(("zremrangebyscore", key))
|
||||
|
||||
def zcard(self, key: str) -> None:
|
||||
self._ops.append(("zcard", key))
|
||||
|
||||
async def execute(self) -> list:
|
||||
results = [self._redis.apply(op) for op in self._ops]
|
||||
self._ops = []
|
||||
return results
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
"""The slice of the Redis client the game store uses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.keys: dict = {}
|
||||
self.zsets: dict = {}
|
||||
|
||||
def pipeline(self, transaction: bool = True) -> _FakePipeline:
|
||||
return _FakePipeline(self)
|
||||
|
||||
def apply(self, op: tuple) -> object:
|
||||
name, *args = op
|
||||
if name == "set":
|
||||
self.keys[args[0]] = "payload"
|
||||
return True
|
||||
if name == "zadd":
|
||||
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||
return 1
|
||||
if name == "zrem":
|
||||
self.zsets.get(args[0], {}).pop(args[1], None)
|
||||
return 1
|
||||
if name == "expire":
|
||||
return True
|
||||
if name == "zremrangebyscore":
|
||||
zset = self.zsets.get(args[0], {})
|
||||
for member in [
|
||||
member
|
||||
for member, score in zset.items()
|
||||
if score <= time.time()
|
||||
]:
|
||||
del zset[member]
|
||||
return 0
|
||||
if name == "zcard":
|
||||
return len(self.zsets.get(args[0], {}))
|
||||
raise AssertionError(f"unexpected command: {name}")
|
||||
|
||||
|
||||
|
||||
class InMemoryGameStoreTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_save_load_roundtrip(self) -> None:
|
||||
@@ -114,6 +194,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
["holder-enter", "holder-exit", "contender"], order
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_count_ongoing_tracks_unfinished_games(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
self.assertEqual(0, await store.count_ongoing())
|
||||
|
||||
session = _session()
|
||||
await store.save(session)
|
||||
self.assertEqual(1, await store.count_ongoing())
|
||||
|
||||
other = _session(game_id="g2", code="CODE02")
|
||||
await store.save(other)
|
||||
self.assertEqual(2, await store.count_ongoing())
|
||||
|
||||
# A finished match is retained for the final board but no longer
|
||||
# counts as ongoing.
|
||||
session.state["finished"] = True
|
||||
await store.save(session)
|
||||
self.assertEqual(1, await store.count_ongoing())
|
||||
other.state["finished"] = True
|
||||
await store.save(other)
|
||||
self.assertEqual(0, await store.count_ongoing())
|
||||
|
||||
@async_test
|
||||
async def test_deadline_queue(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
@@ -138,5 +240,35 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
|
||||
class RedisGameStoreTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_save_marks_games_ongoing_and_finished_ones_stop_counting(self) -> None:
|
||||
redis = _FakeRedis()
|
||||
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
|
||||
session = _session()
|
||||
|
||||
await store.save(session)
|
||||
self.assertEqual(1, await store.count_ongoing())
|
||||
score = redis.zsets[ONGOING_GAMES_KEY][session.id]
|
||||
self.assertGreater(score, time.time())
|
||||
self.assertLessEqual(score, time.time() + 3600)
|
||||
|
||||
session.state["finished"] = True
|
||||
await store.save(session)
|
||||
self.assertNotIn(session.id, redis.zsets[ONGOING_GAMES_KEY])
|
||||
self.assertEqual(0, await store.count_ongoing())
|
||||
|
||||
@async_test
|
||||
async def test_count_ongoing_prunes_expired_entries(self) -> None:
|
||||
redis = _FakeRedis()
|
||||
redis.zsets[ONGOING_GAMES_KEY] = {
|
||||
"stale": time.time() - 10,
|
||||
"live": time.time() + 100,
|
||||
}
|
||||
store = RedisGameStore(redis, _registry()) # type: ignore[arg-type]
|
||||
self.assertEqual(1, await store.count_ongoing())
|
||||
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user