diff --git a/campus_python/json_client/__init__.py b/campus_python/json_client/__init__.py index 84ebe2c..3797fae 100644 --- a/campus_python/json_client/__init__.py +++ b/campus_python/json_client/__init__.py @@ -152,6 +152,16 @@ def set_bearer_authorization(self, token: str) -> None: """ self._session.headers["Authorization"] = "Bearer " + token + def set_default_header(self, name: str, value: str) -> None: + """Set a default header sent with every request from this client. + + Non-browser clients use this to present stable identity on API + calls, e.g. X-Campus-Device (#837) for device attribution of + spans. Calls made inside a host request context may still add + per-request headers on top (tracing.propagation_headers). + """ + self._session.headers[name] = value + def set_unauthorized_hook( self, hook: Callable[[], str | None] | None, diff --git a/campus_python/json_client/interface.py b/campus_python/json_client/interface.py index 2d9e886..e23b150 100644 --- a/campus_python/json_client/interface.py +++ b/campus_python/json_client/interface.py @@ -148,6 +148,18 @@ def set_bearer_authorization(self, token: str) -> None: token (str): Bearer token. """ + def set_default_header(self, name: str, value: str) -> None: + """Set a default header sent with every request (#837). + + Optional capability: the ABC default refuses, so callers can + detect a client that cannot carry default headers instead of + silently dropping them. Implementations should set the header + on their underlying transport (e.g. the requests session). + """ + raise NotImplementedError( + f"{type(self).__name__} does not support default headers" + ) + @abstractmethod def get(self: Self, path: str, query: JsonDict | None = None) -> JsonResponse: """Sends a GET request.""" diff --git a/campus_python/tracing.py b/campus_python/tracing.py index cf9afb3..d55f915 100644 --- a/campus_python/tracing.py +++ b/campus_python/tracing.py @@ -33,6 +33,14 @@ # action journey. Keep the two in lockstep. JOURNEY_ID_HEADER = "X-Journey-ID" +# Mirror of campus.config.DEVICE_ID_HEADER (campus#837): forwarded so a +# child service's span carries the caller's device identity when the +# host stashed one (flask_campus's push_context sets g.device from the +# login session). The receiving middleware reads it only when it knows +# nothing better (g.device absent, no campus_device cookie). Keep the +# two in lockstep. +DEVICE_ID_HEADER = "X-Campus-Device" + # 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" @@ -67,9 +75,12 @@ def propagation_headers() -> dict[str, str]: tracing middleware turns the trace headers into a child span of the caller's span (#794, campus#816); the journeys middleware adopts X-Journey-ID so the child span joins the caller's action journey - (campus#828). The two are independent: trace headers require span - state on flask.g (tracing middleware ran), the journey header only - requires an active journey. + (campus#828); the tracing middleware adopts X-Campus-Device for the + child span's device tag when it knows nothing better (campus#837). + The three are independent: trace headers require span state on + flask.g (tracing middleware ran), the journey header only requires + an active journey, the device header only requires a stashed + g.device. """ headers: dict[str, str] = {} try: @@ -86,6 +97,9 @@ def propagation_headers() -> dict[str, str]: journey_id = getattr(flask.g, "journey_id", None) if journey_id: headers[JOURNEY_ID_HEADER] = journey_id + device_id = getattr(flask.g, "device", None) + if device_id: + headers[DEVICE_ID_HEADER] = str(device_id) return headers diff --git a/poetry.lock b/poetry.lock index 6be6038..ae1e41f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -154,7 +154,7 @@ werkzeug = "^3.0.0" type = "git" url = "https://github.com/nyjc-computing/campus.git" reference = "weekly" -resolved_reference = "14fc6a940f361bea8e79063b14c6f9699b2d0db6" +resolved_reference = "a12f02328ea1fe27163ea79f4fd9ba8869eeb3f3" [[package]] name = "certifi" diff --git a/tests/unit/test_trace_propagation.py b/tests/unit/test_trace_propagation.py index e7e3ecc..58554c7 100644 --- a/tests/unit/test_trace_propagation.py +++ b/tests/unit/test_trace_propagation.py @@ -181,5 +181,103 @@ def test_journey_header_outside_trace_context(self): self.assertNotIn("X-Request-ID", captured["headers"]) +class TestDeviceForwarding(unittest.TestCase): + """Device forwarding on SDK calls (campus#837). + + When the host request carries a device identity (stashed as + flask.g.device by flask_campus's push_context from the login + session), SDK calls forward it as X-Campus-Device so child services' + spans carry the same device tag. + """ + + def test_device_forwarded_inside_traced_request(self): + 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 + flask.g.device = "uid-device-abc123" + client.get("/ping") + + self.assertEqual( + captured["headers"].get("X-Campus-Device"), "uid-device-abc123" + ) + self.assertEqual(captured["headers"].get("X-Request-ID"), TRACE_ID) + + def test_no_device_no_header(self): + """A traced request with no stashed device emits no device header.""" + 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.assertNotIn("X-Campus-Device", captured["headers"]) + + def test_device_header_outside_trace_context(self): + """A stashed device forwards even without an active span.""" + app = flask.Flask(__name__) + client = _make_client() + captured = _capture_send(client) + + with app.test_request_context("/"): + flask.g.device = "uid-device-abc123" + client.get("/ping") + + self.assertEqual( + captured["headers"].get("X-Campus-Device"), "uid-device-abc123" + ) + self.assertNotIn("X-Request-ID", captured["headers"]) + + +class TestSetDefaultHeader(unittest.TestCase): + """JsonClient.set_default_header for non-browser identity (#837).""" + + def test_default_header_sent_on_every_request(self): + client = _make_client() + captured = _capture_send(client) + + client.set_default_header("X-Campus-Device", "uid-device-cli42") + client.get("/ping") + + self.assertEqual( + captured["headers"].get("X-Campus-Device"), "uid-device-cli42" + ) + + def test_propagation_headers_win_over_default(self): + """Per-request trace context rides on top of defaults.""" + app = flask.Flask(__name__) + client = _make_client() + captured = _capture_send(client) + client.set_default_header("X-Campus-Device", "uid-device-default") + + with app.test_request_context("/"): + flask.g.device = "uid-device-session" + client.get("/ping") + + # The request-time forward overwrites the static default for + # this call only; the default survives for later calls. + self.assertEqual( + captured["headers"].get("X-Campus-Device"), "uid-device-session" + ) + + def test_default_header_persists_across_calls(self): + client = _make_client() + captured = _capture_send(client) + client.set_default_header("X-Campus-Device", "uid-device-cli42") + + client.get("/ping") + client.get("/ping") + + self.assertEqual( + captured["headers"].get("X-Campus-Device"), "uid-device-cli42" + ) + + if __name__ == "__main__": unittest.main()