From 7ca0dfbc019c75c2bd7cbc0939dad92ce7b773c6 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 5 Oct 2026 10:53:27 +0800 Subject: [PATCH] feat(tracing): propagate campus trace context on SDK calls Instrument CampusRequest's requests.Session so calls made while the host app handles a traced request carry X-Request-ID / X-Parent-Span-ID and land as child spans in the campus audit pipeline (campus#816 item 1, #92). - New campus_python.tracing mirrors the propagation half of campus.audit.middleware.tracing: same headers, same flask.g contract (trace_id/span_id stashed by the middleware's before_request hook), same _campus_trace_instrumented idempotency marker so the two implementations never double-wrap. - Deliberately self-contained: the SDK must not import the server-side audit client stack to emit two headers. Calls outside a traced request context (startup, background threads) stay unparented. - Makes campus.api's existing session instrumentation redundant but compatible (idempotent across both implementations). --- campus_python/json_client/__init__.py | 7 ++ campus_python/tracing.py | 108 +++++++++++++++++++++ tests/unit/test_trace_propagation.py | 130 ++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 campus_python/tracing.py create mode 100644 tests/unit/test_trace_propagation.py diff --git a/campus_python/json_client/__init__.py b/campus_python/json_client/__init__.py index 5ca1e40..84ebe2c 100644 --- a/campus_python/json_client/__init__.py +++ b/campus_python/json_client/__init__.py @@ -95,6 +95,13 @@ def __init__( # Session to persist headers and connection pooling self._session = requests.Session() self._session.headers.update(self._headers) + # Propagate campus trace context on calls made while the host app + # handles a traced request, so receiving campus services record + # them as child spans (campus#816). No-op outside a request + # context (startup, background threads). + from .. import tracing + + tracing.instrument_requests_session(self._session) # Only set client credentials in server mode # Device mode starts without auth (Bearer token will be set later) if mode == "server": diff --git a/campus_python/tracing.py b/campus_python/tracing.py new file mode 100644 index 0000000..23c1222 --- /dev/null +++ b/campus_python/tracing.py @@ -0,0 +1,108 @@ +"""campus_python.tracing + +Trace-context propagation for SDK HTTP calls (campus-api-python#92, +parent campus#816 item 1). + +When the host application is itself traced — its Flask app wired with +campus.audit.middleware.init_app — every request it handles becomes an +audit span. SDK calls made while handling that request should land as +child spans of it, so the audit waterfall shows which host route fired +each campus call. + +This module mirrors the propagation half of +campus.audit.middleware.tracing (campus service repo): same header +names, same flask.g contract (trace_id / span_id stashed by the +middleware's before_request hook, and the same _campus_trace_instrumented +idempotency marker). It is deliberately self-contained so the SDK does +not import the server-side audit client stack just to emit two headers — +keep the two in lockstep; W3C traceparent interop would extend both. +""" + +import typing + +import requests + +# Same headers as campus.audit.middleware.tracing (#794): X-Request-ID +# carries the host request's trace id; X-Parent-Span-ID carries its span +# id so the receiving campus service records the call as a child span. +TRACE_ID_HEADER = "X-Request-ID" +PARENT_SPAN_ID_HEADER = "X-Parent-Span-ID" + +# Same marker attribute as campus.audit.middleware.tracing so a session +# instrumented by either implementation is left alone by the other. +_INSTRUMENTED_ATTR = "_campus_trace_instrumented" + + +def current_context() -> tuple[str, str] | None: + """Return the (trace_id, span_id) of the host's active traced request. + + Reads the flask.g values stashed by campus.audit.middleware's + before_request hook. Returns None when flask is unavailable, outside + a request context, or when the request carries no active span + (tracing disabled or middleware not installed) — such calls stay + unparented. + """ + try: + import flask + except ImportError: # pragma: no cover - host without flask + return None + if not flask.has_request_context(): + return None + trace_id = getattr(flask.g, "trace_id", None) + span_id = getattr(flask.g, "span_id", None) + if trace_id and span_id: + return trace_id, span_id + return None + + +def propagation_headers() -> dict[str, str]: + """Headers to attach to an outbound SDK call from the active request. + + Empty outside a traced host request. The receiving campus service's + tracing middleware turns these into a child span of the caller's + span (#794, campus#816). + """ + context = current_context() + if context is None: + return {} + return { + TRACE_ID_HEADER: context[0], + PARENT_SPAN_ID_HEADER: context[1], + } + + +def instrument_requests_session(session: requests.Session) -> bool: + """Wrap a requests.Session so its calls carry campus trace context. + + Headers are computed at call time from the host's active request + context, so a shared session stays correct under concurrent requests, + and calls made outside a traced request (startup, background threads) + are left untouched. + + Idempotent: re-instrumenting a session is a no-op. + + Args: + session: The requests.Session to instrument. + + Returns: + True if the session was instrumented now, False if already done. + """ + if getattr(session, _INSTRUMENTED_ATTR, False): + return False + + original_request = session.request + + def request(method, url, **kwargs): + extra = propagation_headers() + if extra: + headers = dict(kwargs.get("headers") or {}) + for name, value in extra.items(): + headers.setdefault(name, value) + kwargs["headers"] = headers + return original_request(method, url, **kwargs) + + # Instance-level override of the bound method: every requests verb + # funnels through Session.request, so one wrap covers all calls. + typing.cast(typing.Any, session).request = request + setattr(session, _INSTRUMENTED_ATTR, True) + return True diff --git a/tests/unit/test_trace_propagation.py b/tests/unit/test_trace_propagation.py new file mode 100644 index 0000000..cbbd78c --- /dev/null +++ b/tests/unit/test_trace_propagation.py @@ -0,0 +1,130 @@ +"""Tests for campus trace-context propagation on SDK calls (#92). + +When the host app is traced (campus.audit.middleware.init_app), the +middleware stashes trace_id/span_id on flask.g for each request. SDK +sessions must attach those as X-Request-ID / X-Parent-Span-ID so the +receiving campus service records the call as a child span — and attach +nothing when there is no active traced request. +""" + +import unittest + +import flask +import requests + +from campus_python import tracing +from campus_python.json_client import CampusRequest + +TRACE_ID = "a" * 32 +SPAN_ID = "b" * 16 + + +def _make_client() -> CampusRequest: + """Build an unauthenticated client against a dummy base URL.""" + return CampusRequest(base_url="http://testsuite.invalid", mode="device") + + +def _capture_send(client: CampusRequest) -> dict: + """Replace the session transport with a capture stub. + + Returns a dict that receives the outgoing request's headers. + """ + captured: dict = {} + + def fake_send(prepared: requests.PreparedRequest, **_kwargs): + captured["headers"] = dict(prepared.headers) + response = requests.Response() + response.status_code = 200 + response.headers["Content-Type"] = "application/json" + response._content = b"{}" + return response + + client._session.send = fake_send + return captured + + +class TestTracePropagation(unittest.TestCase): + """Trace headers on outbound SDK calls (#92).""" + + def test_headers_inside_traced_request(self): + """Calls inside a traced host request carry both trace headers.""" + app = flask.Flask(__name__) + client = _make_client() + captured = _capture_send(client) + + with app.test_request_context("/"): + flask.g.trace_id = TRACE_ID + flask.g.span_id = SPAN_ID + client.get("/ping") + + self.assertEqual(captured["headers"].get("X-Request-ID"), TRACE_ID) + self.assertEqual( + captured["headers"].get("X-Parent-Span-ID"), SPAN_ID + ) + + def test_no_headers_outside_request_context(self): + """Calls outside any request context stay unparented.""" + client = _make_client() + captured = _capture_send(client) + + client.get("/ping") + + self.assertNotIn("X-Request-ID", captured["headers"]) + self.assertNotIn("X-Parent-Span-ID", captured["headers"]) + + def test_no_headers_without_active_span(self): + """A request context without middleware-stashed span state + (tracing disabled or middleware absent) emits no headers.""" + app = flask.Flask(__name__) + client = _make_client() + captured = _capture_send(client) + + with app.test_request_context("/"): + client.get("/ping") + + self.assertNotIn("X-Request-ID", captured["headers"]) + self.assertNotIn("X-Parent-Span-ID", captured["headers"]) + + def test_caller_headers_take_precedence(self): + """Explicit per-call headers are never overwritten.""" + app = flask.Flask(__name__) + client = _make_client() + captured = _capture_send(client) + + with app.test_request_context("/"): + flask.g.trace_id = TRACE_ID + flask.g.span_id = SPAN_ID + client._session.request( + "GET", + "http://testsuite.invalid/ping", + headers={"X-Request-ID": "custom-trace-id"}, + ) + + self.assertEqual(captured["headers"].get("X-Request-ID"), "custom-trace-id") + self.assertEqual( + captured["headers"].get("X-Parent-Span-ID"), SPAN_ID + ) + + def test_instrumentation_is_idempotent(self): + """Sessions are instrumented at construction (#92); re-instrumenting + (e.g. campus.api's init_app path) must be a no-op, not a second wrap.""" + client = _make_client() + + # Already instrumented at CampusRequest construction. + self.assertFalse( + tracing.instrument_requests_session(client._session) + ) + + # Still exactly one wrap: the header logic runs once per call. + app = flask.Flask(__name__) + captured = _capture_send(client) + with app.test_request_context("/"): + flask.g.trace_id = TRACE_ID + flask.g.span_id = SPAN_ID + client.get("/ping") + + self.assertEqual(captured["headers"].get("X-Request-ID"), TRACE_ID) + + +if __name__ == "__main__": + unittest.main()