Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions campus_python/json_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions campus_python/json_client/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
20 changes: 17 additions & 3 deletions campus_python/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 98 additions & 0 deletions tests/unit/test_trace_propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading