Add optional OpenTelemetry traces, metrics and OTLP logs
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""OpenTelemetry wiring tests.
|
||||
|
||||
The actual OpenTelemetry packages are an optional extra (``kca-app[otel]``),
|
||||
so the kaya-otel mixin is stubbed here. Provider-dependent tests live in
|
||||
``test_telemetry.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca.app import otel_mixin_from_settings
|
||||
from kca.config import Settings
|
||||
from kca.telemetry import session_tag_hook
|
||||
|
||||
|
||||
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():
|
||||
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": "kca-test",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
|
||||
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS": "15000",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertEqual("kca-test", mixin.kwargs["service_name"])
|
||||
self.assertEqual("http://collector:4318", mixin.kwargs["endpoint"])
|
||||
self.assertEqual(
|
||||
{"Authorization": "Bearer t", "X-Tenant": "one"},
|
||||
mixin.kwargs["headers"],
|
||||
)
|
||||
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
|
||||
self.assertEqual(15000, mixin.kwargs["metric_export_interval_millis"])
|
||||
self.assertIs(session_tag_hook, mixin.kwargs["server_response_hook"])
|
||||
self.assertIs(session_tag_hook, mixin.kwargs["websocket_close_hook"])
|
||||
|
||||
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("kca", mixin.kwargs["service_name"])
|
||||
self.assertIsNone(mixin.kwargs["endpoint"])
|
||||
self.assertIsNone(mixin.kwargs["headers"])
|
||||
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
|
||||
|
||||
def test_providers_are_passed_through(self):
|
||||
with _stub_kaya_otel():
|
||||
meter = object()
|
||||
tracer = object()
|
||||
mixin = otel_mixin_from_settings(
|
||||
_settings({"OTEL_ENABLED": "1"}),
|
||||
meter_provider=meter,
|
||||
tracer_provider=tracer,
|
||||
)
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertIs(meter, mixin.kwargs["meter_provider"])
|
||||
self.assertIs(tracer, mixin.kwargs["tracer_provider"])
|
||||
|
||||
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,/api/docs",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertEqual(("/api/health", "/api/docs"), 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()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Telemetry helper tests.
|
||||
|
||||
Provider-dependent tests are skipped unless the optional OpenTelemetry
|
||||
packages are installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
from kaya.session import Session
|
||||
|
||||
from kca.config import Settings
|
||||
from kca.models import User
|
||||
from kca.telemetry import (
|
||||
CountingInMemorySessionStore,
|
||||
CountingRedisSessionStore,
|
||||
_NoOtelLogs,
|
||||
_signal_endpoint,
|
||||
otel_headers,
|
||||
session_tag_hook,
|
||||
telemetry_from_settings,
|
||||
)
|
||||
|
||||
from tests.helpers.asynctest import async_test
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk._logs import LoggingHandler # noqa: F401
|
||||
from opentelemetry.sdk.metrics import MeterProvider # noqa: F401
|
||||
from opentelemetry.sdk.trace import TracerProvider # noqa: F401
|
||||
|
||||
HAVE_OTEL = True
|
||||
except ImportError: # pragma: no cover - installs without the otel extra
|
||||
HAVE_OTEL = False
|
||||
|
||||
|
||||
def _settings(env: dict) -> Settings:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return Settings.from_env()
|
||||
|
||||
|
||||
class _FakeSessions:
|
||||
def __init__(self, count: int = 0) -> None:
|
||||
self.value: Optional[int] = count
|
||||
|
||||
async def count(self) -> int:
|
||||
assert self.value is not None
|
||||
return self.value
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self, keys: list) -> None:
|
||||
self.keys = keys
|
||||
|
||||
async def scan_iter(self, match: str = "", count: int = 0):
|
||||
prefix = match.rstrip("*")
|
||||
for key in self.keys:
|
||||
if key.startswith(prefix):
|
||||
yield key
|
||||
|
||||
|
||||
class _RecordedSpan:
|
||||
def __init__(self) -> None:
|
||||
self.attributes: dict = {}
|
||||
|
||||
def set_attribute(self, key: str, value) -> None:
|
||||
self.attributes[key] = value
|
||||
|
||||
|
||||
class TelemetrySetupTest(unittest.TestCase):
|
||||
def test_disabled_returns_none(self) -> None:
|
||||
self.assertIsNone(telemetry_from_settings(_settings({}), _FakeSessions()))
|
||||
|
||||
def test_missing_otel_dependencies_raise(self) -> None:
|
||||
settings = _settings({"OTEL_ENABLED": "true"})
|
||||
missing = "opentelemetry.exporter.otlp.proto.http._log_exporter"
|
||||
with patch.dict(sys.modules, {missing: None}):
|
||||
with self.assertRaises(RuntimeError):
|
||||
telemetry_from_settings(settings, _FakeSessions())
|
||||
|
||||
|
||||
class ProviderSetupTest(unittest.TestCase):
|
||||
@unittest.skipUnless(HAVE_OTEL, "OpenTelemetry extra is not installed")
|
||||
def test_builds_providers_and_installs_log_handler(self) -> None:
|
||||
telemetry = telemetry_from_settings(
|
||||
_settings({"OTEL_ENABLED": "1", "OTEL_SERVICE_NAME": "kca-test"}),
|
||||
_FakeSessions(),
|
||||
)
|
||||
assert telemetry is not None
|
||||
self.assertIsNotNone(telemetry.meter_provider)
|
||||
self.assertIsNotNone(telemetry.tracer_provider)
|
||||
self.assertIsNotNone(telemetry.logger_provider)
|
||||
self.assertIn(telemetry.log_handler, logging.getLogger().handlers)
|
||||
try:
|
||||
self.assertIsNotNone(telemetry.mixin)
|
||||
finally:
|
||||
logging.getLogger().removeHandler(telemetry.log_handler)
|
||||
telemetry.log_handler.close()
|
||||
telemetry.logger_provider.shutdown()
|
||||
telemetry.tracer_provider.shutdown()
|
||||
telemetry.meter_provider.shutdown()
|
||||
|
||||
|
||||
class HeaderAndFilterTest(unittest.TestCase):
|
||||
def test_otel_headers(self) -> None:
|
||||
settings = _settings(
|
||||
{"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer x, X-Tenant=one"}
|
||||
)
|
||||
self.assertEqual(
|
||||
{"Authorization": "Bearer x", "X-Tenant": "one"},
|
||||
otel_headers(settings),
|
||||
)
|
||||
|
||||
def test_empty_headers(self) -> None:
|
||||
self.assertIsNone(otel_headers(_settings({})))
|
||||
|
||||
def test_signal_endpoint(self) -> None:
|
||||
self.assertEqual(
|
||||
"http://collector:4318/v1/traces",
|
||||
_signal_endpoint("http://collector:4318", "traces"),
|
||||
)
|
||||
self.assertIsNone(_signal_endpoint(None, "metrics"))
|
||||
|
||||
def test_no_otel_logs_filter(self) -> None:
|
||||
filter_ = _NoOtelLogs()
|
||||
self.assertFalse(filter_.filter(logging.makeLogRecord({"name": "opentelemetry.sdk"})))
|
||||
self.assertTrue(filter_.filter(logging.makeLogRecord({"name": "kca.app"})))
|
||||
self.assertTrue(filter_.filter(logging.makeLogRecord({"name": "granian.server"})))
|
||||
|
||||
|
||||
class SessionCountTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_in_memory_counts_live_sessions(self) -> None:
|
||||
now = [1000.0]
|
||||
store = CountingInMemorySessionStore(clock=lambda: now[0])
|
||||
await store.save("a", Session("a", {"user": "alice"}), max_age=10)
|
||||
await store.save("b", Session("b", {"user": "bob"}), max_age=10)
|
||||
await store.save("c", Session("c", {"user": "carol"}))
|
||||
self.assertEqual(3, await store.count())
|
||||
now[0] += 11
|
||||
self.assertEqual(1, await store.count())
|
||||
await store.delete("c")
|
||||
self.assertEqual(0, await store.count())
|
||||
|
||||
@async_test
|
||||
async def test_redis_counts_only_session_keys(self) -> None:
|
||||
store = CountingRedisSessionStore(
|
||||
_FakeRedis(["kaya:session:a", "kaya:session:b", "kca:other"])
|
||||
)
|
||||
self.assertEqual(2, await store.count())
|
||||
|
||||
|
||||
class SessionTagHookTest(unittest.TestCase):
|
||||
def test_tags_authenticated_session(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(
|
||||
session=Session("sid", {"oidc:default:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(b"sid").hexdigest()[:16],
|
||||
span.attributes["session.id"],
|
||||
)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
|
||||
def test_tags_user_of_any_provider(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(
|
||||
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
|
||||
def test_tags_session_without_user(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
session_tag_hook(span, SimpleNamespace(session=Session("sid", {})))
|
||||
self.assertIn("session.id", span.attributes)
|
||||
self.assertNotIn("user.id", span.attributes)
|
||||
|
||||
def test_tags_token_user_cached_on_request(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(session=Session("sid", {}))
|
||||
setattr(carrier, "_kca_user", User(sub="token-user", display_name="Token"))
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual("token-user", span.attributes["user.id"])
|
||||
|
||||
def test_ignores_requests_without_session(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
session_tag_hook(span, SimpleNamespace())
|
||||
self.assertEqual({}, span.attributes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user