Tag traced requests with the session id and OIDC sub

This commit is contained in:
2026-09-28 05:11:22 +08:00
parent bcd1371019
commit 834f1f26a9
5 changed files with 173 additions and 3 deletions
+10
View File
@@ -16,6 +16,7 @@ from unittest.mock import patch
from tavolo.app import otel_mixin_from_settings
from tavolo.config import Settings
from tavolo.telemetry import session_tag_hook
def _settings(env: dict) -> Settings:
@@ -58,8 +59,17 @@ class OTelMixinFromSettingsTests(unittest.TestCase):
"endpoint": "http://collector:4318",
"headers": {"Authorization": "Bearer t", "X-Tenant": "one"},
"excluded_paths": ("/api/health",),
"server_response_hook": session_tag_hook,
"websocket_close_hook": session_tag_hook,
}, mixin.kwargs)
def test_session_tag_hooks_are_wired(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
assert isinstance(mixin, _StubOTelMixin)
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"}))
+115
View File
@@ -5,6 +5,7 @@ so the provider-dependent tests skip when they are not installed.
"""
from __future__ import annotations
import hashlib
import logging
import os
import sys
@@ -19,6 +20,7 @@ from tavolo.telemetry import (
CountingRedisSessionStore,
TelemetryMixin,
_NoOtelLogs,
session_tag_hook,
telemetry_from_settings,
)
from tavolo.config import Settings
@@ -137,6 +139,119 @@ class NoOtelLogsFilterTest(unittest.TestCase):
)
class _RecordedSpan:
def __init__(self) -> None:
self.attributes: dict = {}
def set_attribute(self, key: str, value) -> None:
self.attributes[key] = value
class SessionTagHookTest(unittest.TestCase):
def test_tags_authenticated_session(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session("sid", {"oidc_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_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_user_when_session_id_not_yet_minted(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session(None, {"oidc_user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertNotIn("session.id", span.attributes)
self.assertEqual("alice", span.attributes["user.id"])
def test_missing_session_is_a_noop(self) -> None:
span = _RecordedSpan()
session_tag_hook(span, SimpleNamespace())
self.assertEqual({}, span.attributes)
def test_malformed_user_is_ignored(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(session=Session("sid", {"oidc_user": "alice"}))
session_tag_hook(span, carrier)
self.assertIn("session.id", span.attributes)
self.assertNotIn("user.id", span.attributes)
@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed")
class SessionTagIntegrationTest(unittest.TestCase):
"""The hook is wired as kaya-otel's response hook, so it sees the
session after the session mixin persisted and minted it."""
@async_test
async def test_spans_carry_hashed_session_and_oidc_sub(self) -> None:
from httpx import ASGITransport, AsyncClient
from kaya.core import HttpContext, KayaApp
from kaya.otel import OTelMixin
from kaya.session import InMemorySessionStore, SessionMixin
span_exporter = InMemorySpanExporter()
tracer_provider = TracerProvider(
resource=Resource.create({"service.name": "test"})
)
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
app = KayaApp(
mixins=[
OTelMixin(
service_name="test",
tracer_provider=tracer_provider,
meter_provider=MeterProvider(
metric_readers=[InMemoryMetricReader()]
),
server_response_hook=session_tag_hook,
websocket_close_hook=session_tag_hook,
),
SessionMixin(InMemorySessionStore()),
]
)
@app.GET("/login")
async def login(ctx: HttpContext) -> None:
session = ctx.session
assert session is not None
session["oidc_user"] = {"sub": "alice"}
await ctx.send_str(200, "ok")
@app.GET("/me")
async def me(ctx: HttpContext) -> None:
await ctx.send_str(200, "ok")
transport = ASGITransport(app=app) # type: ignore[arg-type]
async with AsyncClient(
transport=transport, base_url="http://127.0.0.1"
) as client:
first = await client.get("/login")
second = await client.get("/me")
self.assertEqual(200, first.status_code)
self.assertEqual(200, second.status_code)
# The cookie value is the session id; only its digest is exported.
cookie = first.cookies["session_id"]
digest = hashlib.sha256(cookie.encode()).hexdigest()[:16]
spans = {span.name: span for span in span_exporter.get_finished_spans()}
self.assertEqual({"GET /login", "GET /me"}, set(spans))
for name, span in spans.items():
attributes = span.attributes
assert attributes is not None
self.assertEqual(digest, attributes["session.id"], name)
self.assertEqual("alice", attributes["user.id"], name)
def _metric_points(reader) -> dict:
"""Map metric name -> list of (attributes, value) data points."""
data = reader.get_metrics_data()