Add optional OpenTelemetry integration via kaya-otel
CI / Build and push docker image (push) Successful in 1m31s
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.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user