diff --git a/deploy/k8s/tavolo.yaml b/deploy/k8s/tavolo.yaml index f28557f..82ac383 100644 --- a/deploy/k8s/tavolo.yaml +++ b/deploy/k8s/tavolo.yaml @@ -71,6 +71,13 @@ data: # CORS_ALLOW_CREDENTIALS: "false" # CORS_EXPOSE_HEADERS: "" # 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_CLIENT_ID: tavolo OIDC_POST_LOGIN_REDIRECT: / diff --git a/docker-compose.yml b/docker-compose.yml index 1af2814..6f0ce5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,6 +122,13 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-} CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-} 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: - "127.0.0.1:${APP_PORT:-8080}:8080" diff --git a/server/README.md b/server/README.md index 338862d..42f79c2 100644 --- a/server/README.md +++ b/server/README.md @@ -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_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser | | `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 | ## Logging diff --git a/server/pyproject.toml b/server/pyproject.toml index 3cc7445..4ab18a8 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -32,6 +32,9 @@ dev = [ "mypy", "httpx-ws", ] +otel = [ + "kaya-otel>=0.0.4", +] [tool.setuptools.packages.find] where = ["src"] diff --git a/server/requirements.txt b/server/requirements.txt index 21cdd78..b08533a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -50,7 +50,7 @@ idna==3.19 # httpx iso8601==2.1.0 # via tortoise-orm -kaya-core==0.0.3 +kaya-core==0.0.4 # via # kaya-cors # kaya-oidc @@ -58,20 +58,20 @@ kaya-core==0.0.3 # kaya-rsgi # kaya-session # tavolo (pyproject.toml) -kaya-cors==0.0.3 +kaya-cors==0.0.4 # via tavolo (pyproject.toml) -kaya-oidc==0.0.3 +kaya-oidc==0.0.4 # via tavolo (pyproject.toml) -kaya-openapi==0.0.3 +kaya-openapi==0.0.4 # via tavolo (pyproject.toml) -kaya-rsgi==0.0.3 +kaya-rsgi==0.0.4 # via tavolo (pyproject.toml) -kaya-session==0.0.3 +kaya-session==0.0.4 # via # kaya-oidc # kaya-session-redis # tavolo (pyproject.toml) -kaya-session-redis==0.0.3 +kaya-session-redis==0.0.4 # via tavolo (pyproject.toml) pwo==0.1.2 # via diff --git a/server/src/tavolo/app.py b/server/src/tavolo/app.py index f191215..2de354d 100644 --- a/server/src/tavolo/app.py +++ b/server/src/tavolo/app.py @@ -12,7 +12,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins: ``/api/openapi.json`` and a Swagger UI at ``/api/docs``) 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 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 if settings.redis_url is not None: # 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, 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) if cors_mixin is not None: # First in the list: preflight requests are answered before the session diff --git a/server/src/tavolo/config.py b/server/src/tavolo/config.py index 53c0483..0338245 100644 --- a/server/src/tavolo/config.py +++ b/server/src/tavolo/config.py @@ -113,6 +113,16 @@ class Settings: cors_allow_credentials: bool cors_expose_headers: Optional[Tuple[str, ...]] 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 def from_env() -> "Settings": @@ -159,6 +169,14 @@ class Settings: cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"), cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"), 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",), ) diff --git a/server/tests/test_config.py b/server/tests/test_config.py index d7c7012..87ba845 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -130,5 +130,40 @@ class CorsSettingsTests(unittest.TestCase): 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__": unittest.main() diff --git a/server/tests/test_otel.py b/server/tests/test_otel.py new file mode 100644 index 0000000..a204a2f --- /dev/null +++ b/server/tests/test_otel.py @@ -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()