11 Commits
Author SHA1 Message Date
woggioni af480bb9b7 Add optional OpenTelemetry integration via kaya-otel
CI / Build and push docker image (push) Successful in 1m31s
Adds an OTEL_ENABLED opt-in that assembles kaya-otel's OTelMixin into
the app, exporting HTTP/WebSocket traces and request metrics to an
OTLP/HTTP collector. Configured through OTEL_SERVICE_NAME,
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS and
OTEL_EXCLUDED_PATHS (defaults to the /api/health probe endpoint).

kaya-otel is an optional 'otel' extra imported lazily, so default
installs and the test suite do not need the OpenTelemetry packages.
The kaya dependencies are bumped to 0.0.4, which kaya-otel requires.
2026-09-19 10:09:31 +08:00
woggioni 93d041b004 Add chess-style Elo ratings for players
Each player's rating starts at 1500 and updates transactionally with
every finished match: a team's rating is the mean of its two members
and the standard K=32 formula decides the zero-sum delta applied to
both members of a team. Ratings are per game type in a new
player_rating table; match_player records each match's elo_delta.

- GET /api/leaderboard exposes elo and sorts by it
- GET /api/me/matches includes per-player elo deltas
- new GET /api/me/ratings returns the caller's rating per game type
- frontend: Elo column on the leaderboard, per-match delta in the
  history page, current rating in the lobby
- python -m tavolo.backfill_elo recomputes all ratings from the
  recorded match history (one-off backfill for existing matches)
2026-09-19 10:09:24 +08:00
woggioni ebeccc937d optimized dockerfile for caching
CI / Build and push docker image (push) Successful in 1m14s
2026-09-18 19:28:33 +08:00
woggioni e0896b4a95 Configure CORS headers from environment variables
CI / Build and push docker image (push) Successful in 3m24s
2026-09-18 19:18:27 +08:00
woggioni e2091ee4df updated Docker builder 2026-09-18 19:18:04 +08:00
woggioni 61f3539c4e Reconnect the game websocket after connectivity loss
The socket had no recovery path: a mid-game drop left the table showing
stale state with no indication, and plays were silently swallowed by the
dead channel. Surface the server close code from ws::connect and add a
reconnect driver in the game page: transient losses retry with
exponential backoff (capped, then a manual Retry), while deliberate
closes (session expired, game gone) stop retrying. Reconnects are free
resyncs because the server pushes a full state snapshot on connect.

Also gate card clicks while disconnected, show a connection banner, and
drop the ticking interval and pending retries on unmount.
2026-09-18 19:16:39 +08:00
woggioni 8fea4fac74 Fix deadline consumer startup under granian RSGI
The mixin received the event loop from Kaya but ignored it, calling
asyncio.get_running_loop() instead. Under granian RSGI __rsgi_init__ runs
before the loop starts, so that raised RuntimeError and killed the
worker. Use the loop passed to setup(), falling back to the running loop
for the lazy calls from sync_deadline().
2026-09-18 19:16:31 +08:00
woggioni c5b6c84408 Show own captured and scopa counts in game view 2026-09-18 19:16:17 +08:00
woggioni 2d1a13f663 Drive timeouts from a shared Redis deadline queue
Turn auto-play and hand-end auto-continue were process-local asyncio
tasks armed only by client connects and state broadcasts: with no
sockets connected the next turn's timer was never armed, a hand-end
timer died with its worker, and neither survived a pod restart.

Deadlines are now driven by the absolute timestamps persisted on the
game state and enqueued in a shared Redis sorted set. Every worker runs
a consumer that fires due entries under the per-game lock after
revalidating them against the live state, so timeouts no longer depend
on any player being connected and survive the death of any worker.
Delivery is at-least-once: entries are removed only after processing,
and revalidation makes duplicate deliveries no-ops.

Queue entries carry the deadline as integer epoch milliseconds, which
also serves as the revalidation token, and the score derives from the
same value.
2026-09-18 19:16:07 +08:00
woggioni c1e7f70a3b Add configurable napola rule with instant win on a full denari sweep 2026-09-18 19:14:54 +08:00
woggioni bf04a9b38d Auto-dismiss error toasts after 10 seconds 2026-09-18 19:14:49 +08:00
10 changed files with 226 additions and 14 deletions
+7
View File
@@ -71,6 +71,13 @@ data:
# CORS_ALLOW_CREDENTIALS: "false" # CORS_ALLOW_CREDENTIALS: "false"
# CORS_EXPOSE_HEADERS: "" # CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600" # CORS_MAX_AGE: "600"
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set. Requires the otel extra in the image.
# OTEL_ENABLED: "true"
# OTEL_SERVICE_NAME: "tavolo"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
# OIDC (provider lives in another namespace). # OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: / OIDC_POST_LOGIN_REDIRECT: /
+7
View File
@@ -122,6 +122,13 @@ services:
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-} CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-} CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-} CORS_MAX_AGE: ${CORS_MAX_AGE:-}
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
# Requires the otel extra in the image (see server/pyproject.toml).
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
+10 -6
View File
@@ -48,16 +48,20 @@ RUN --mount=type=cache,target=/var/cache/apk \
WORKDIR /build WORKDIR /build
COPY server/pyproject.toml server/README.md server/requirements.txt ./
COPY server/src/ ./src/
# aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/
COPY server/requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \ python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \ && /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt . && /opt/venv/bin/pip install -r requirements.txt
COPY server/pyproject.toml server/README.md ./
COPY server/src/ ./src/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install .
# aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/
# --- Runtime --------------------------------------------------------------- # --- Runtime ---------------------------------------------------------------
FROM alpine:3.24 FROM alpine:3.24
+5
View File
@@ -69,6 +69,11 @@ All configuration comes from environment variables (see `.env.example`):
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests | | `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser | | `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response | | `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo[otel]`) |
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Logging ## Logging
+3
View File
@@ -32,6 +32,9 @@ dev = [
"mypy", "mypy",
"httpx-ws", "httpx-ws",
] ]
otel = [
"kaya-otel>=0.0.4",
]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+7 -7
View File
@@ -50,7 +50,7 @@ idna==3.19
# httpx # httpx
iso8601==2.1.0 iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.3 kaya-core==0.0.4
# via # via
# kaya-cors # kaya-cors
# kaya-oidc # kaya-oidc
@@ -58,20 +58,20 @@ kaya-core==0.0.3
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
kaya-cors==0.0.3 kaya-cors==0.0.4
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-oidc==0.0.3 kaya-oidc==0.0.4
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-openapi==0.0.3 kaya-openapi==0.0.4
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-rsgi==0.0.3 kaya-rsgi==0.0.4
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-session==0.0.3 kaya-session==0.0.4
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
kaya-session-redis==0.0.3 kaya-session-redis==0.0.4
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
pwo==0.1.2 pwo==0.1.2
# via # via
+46 -1
View File
@@ -12,7 +12,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
``/api/openapi.json`` and a Swagger UI at ``/api/docs``) ``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
``CORS_*`` environment variables (see :mod:`tavolo.config`). ``CORS_*`` environment variables (see :mod:`tavolo.config`). A
:class:`~kaya.otel.OTelMixin` (optional ``otel`` extra) is prepended when
``OTEL_ENABLED`` is set, adding OpenTelemetry traces and metrics.
Live games are kept in :data:`game_store` (Redis when configured, in-memory Live games are kept in :data:`game_store` (Redis when configured, in-memory
otherwise). Routes and the websocket handlers are registered by importing otherwise). Routes and the websocket handlers are registered by importing
@@ -62,6 +64,36 @@ def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
) )
def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]:
"""Build a :class:`~kaya.otel.OTelMixin` from the OTEL_* settings.
Returns ``None`` — telemetry disabled — unless ``OTEL_ENABLED`` is
truthy. kaya-otel is an optional dependency (the ``otel`` extra), so it
is imported lazily here: default installs and the test suite never need
the OpenTelemetry packages.
"""
if not settings.otel_enabled:
return None
try:
from kaya.otel import OTelMixin
except ImportError as exc:
raise RuntimeError(
"OTEL_ENABLED is set but kaya-otel is not installed; "
"install tavolo with the 'otel' extra"
) from exc
headers = dict(
pair.split("=", 1)
for pair in (settings.otel_exporter_headers or ())
if "=" in pair
)
return OTelMixin(
service_name=settings.otel_service_name,
endpoint=settings.otel_exporter_endpoint,
headers=headers or None,
excluded_paths=settings.otel_excluded_paths,
)
session_store: SessionStore session_store: SessionStore
if settings.redis_url is not None: if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually # Lazy client: no connection is opened until a session is actually
@@ -105,6 +137,19 @@ tortoise_mixin = TortoiseMixin(
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
DeadlineSchedulerMixin(game_store)] DeadlineSchedulerMixin(game_store)]
otel_mixin = otel_mixin_from_settings(settings)
if otel_mixin is not None:
# First in the list: before hooks run in registration order (after hooks
# in reverse), so the span covers session loading, OIDC handling and the
# handler itself. CORS, when enabled, is still prepended before it so
# preflight short-circuits stay untraced.
mixins.insert(0, otel_mixin)
log.info(
"OpenTelemetry enabled: service=%s endpoint=%s",
settings.otel_service_name,
settings.otel_exporter_endpoint or "(OTLP default)",
)
cors_mixin = cors_mixin_from_settings(settings) cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None: if cors_mixin is not None:
# First in the list: preflight requests are answered before the session # First in the list: preflight requests are answered before the session
+18
View File
@@ -113,6 +113,16 @@ class Settings:
cors_allow_credentials: bool cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]] cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int cors_max_age: int
# OpenTelemetry (kaya-otel's OTelMixin). Disabled unless OTEL_ENABLED is
# truthy; the exporter endpoint falls back to the OTLP/HTTP default
# (localhost:4318) when OTEL_EXPORTER_OTLP_ENDPOINT is unset.
otel_enabled: bool
otel_service_name: str
otel_exporter_endpoint: Optional[str]
otel_exporter_headers: Optional[Tuple[str, ...]]
# Paths excluded from tracing and metrics (exact matches). Defaults to
# the health endpoint, which k8s probes would otherwise spam.
otel_excluded_paths: Tuple[str, ...]
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -159,6 +169,14 @@ class Settings:
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"), cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"), cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")), cors_max_age=int(_env("CORS_MAX_AGE", "600")),
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces
# and metrics via OTLP/HTTP (requires the ``otel`` extra).
otel_enabled=_env_bool("OTEL_ENABLED"),
otel_service_name=_env("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
# Comma-separated key=value pairs, e.g. "Authorization=Bearer x".
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
) )
+35
View File
@@ -130,5 +130,40 @@ class CorsSettingsTests(unittest.TestCase):
self.assertEqual(3600, settings.cors_max_age) self.assertEqual(3600, settings.cors_max_age)
class OTelSettingsTests(unittest.TestCase):
def test_otel_disabled_by_default(self):
settings = _settings({})
self.assertFalse(settings.otel_enabled)
self.assertEqual("tavolo", settings.otel_service_name)
self.assertIsNone(settings.otel_exporter_endpoint)
self.assertIsNone(settings.otel_exporter_headers)
def test_otel_enabled_parses_boolean(self):
for value in ("1", "true", "TRUE", "yes", "on"):
self.assertTrue(_settings({"OTEL_ENABLED": value}).otel_enabled)
for value in ("0", "false", "no", "off", "anything-else"):
self.assertFalse(_settings({"OTEL_ENABLED": value}).otel_enabled)
def test_otel_settings_are_passed_through(self):
settings = _settings({
"OTEL_SERVICE_NAME": "cards",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
})
self.assertEqual("cards", settings.otel_service_name)
self.assertEqual("http://collector:4318", settings.otel_exporter_endpoint)
self.assertEqual(
("Authorization=Bearer t", "X-Tenant=one"),
settings.otel_exporter_headers,
)
def test_otel_excluded_paths_defaults_to_health_endpoint(self):
self.assertEqual(("/api/health",), _settings({}).otel_excluded_paths)
def test_otel_excluded_paths_parses_comma_separated_list(self):
settings = _settings({"OTEL_EXCLUDED_PATHS": "/api/health, /metrics"})
self.assertEqual(("/api/health", "/metrics"), settings.otel_excluded_paths)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for the OpenTelemetry wiring in :mod:`tavolo.app`.
The mixin under test is kaya-otel's :class:`~kaya.otel.OTelMixin`, an
optional dependency (the ``otel`` extra); these tests only verify that
:func:`tavolo.app.otel_mixin_from_settings` maps the ``OTEL_*`` settings
onto mixin construction. The ``kaya.otel`` module is stubbed in
``sys.modules`` so the suite does not need the extra installed.
"""
from __future__ import annotations
import os
import sys
import types
import unittest
from unittest.mock import patch
from tavolo.app import otel_mixin_from_settings
from tavolo.config import Settings
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
class _StubOTelMixin:
def __init__(self, **kwargs):
self.kwargs = kwargs
def _stub_kaya_otel():
"""Install a fake ``kaya.otel`` module and return it."""
module = types.ModuleType("kaya.otel")
module.OTelMixin = _StubOTelMixin # type: ignore[attr-defined]
return patch.dict(sys.modules, {"kaya.otel": module})
class OTelMixinFromSettingsTests(unittest.TestCase):
def test_disabled_by_default(self):
self.assertIsNone(otel_mixin_from_settings(_settings({})))
def test_enabled_by_otel_enabled(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
self.assertIsNotNone(mixin)
def test_settings_are_passed_through(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({
"OTEL_ENABLED": "true",
"OTEL_SERVICE_NAME": "cards",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual({
"service_name": "cards",
"endpoint": "http://collector:4318",
"headers": {"Authorization": "Bearer t", "X-Tenant": "one"},
"excluded_paths": ("/api/health",),
}, mixin.kwargs)
def test_defaults_when_only_enabled(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "on"}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual("tavolo", mixin.kwargs["service_name"])
self.assertIsNone(mixin.kwargs["endpoint"])
self.assertIsNone(mixin.kwargs["headers"])
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
def test_excluded_paths_are_passed_through(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({
"OTEL_ENABLED": "1",
"OTEL_EXCLUDED_PATHS": "/api/health,/metrics",
}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual(("/api/health", "/metrics"), mixin.kwargs["excluded_paths"])
def test_missing_extra_raises_runtime_error(self):
with patch.dict(sys.modules, {"kaya.otel": None}):
with self.assertRaises(RuntimeError):
otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
if __name__ == "__main__":
unittest.main()