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
38 changes: 28 additions & 10 deletions campus_python/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
TRACE_ID_HEADER = "X-Request-ID"
PARENT_SPAN_ID_HEADER = "X-Parent-Span-ID"

# Mirror of campus.audit.middleware.journeys.ACTION_JOURNEY_HEADER
# (campus#828): forwarded so a child service's span joins the caller's
# action journey. Keep the two in lockstep.
JOURNEY_ID_HEADER = "X-Journey-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"
Expand Down Expand Up @@ -58,17 +63,30 @@ def current_context() -> tuple[str, str] | 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).
Empty outside a request context. The receiving campus service's
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.
"""
context = current_context()
if context is None:
return {}
return {
TRACE_ID_HEADER: context[0],
PARENT_SPAN_ID_HEADER: context[1],
}
headers: dict[str, str] = {}
try:
import flask
except ImportError: # pragma: no cover - host without flask
return headers
if not flask.has_request_context():
return headers
trace_id = getattr(flask.g, "trace_id", None)
span_id = getattr(flask.g, "span_id", None)
if trace_id and span_id:
headers[TRACE_ID_HEADER] = trace_id
headers[PARENT_SPAN_ID_HEADER] = span_id
journey_id = getattr(flask.g, "journey_id", None)
if journey_id:
headers[JOURNEY_ID_HEADER] = journey_id
return headers


def instrument_requests_session(session: requests.Session) -> bool:
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/test_trace_propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,60 @@ def test_instrumentation_is_idempotent(self):
self.assertEqual(captured["headers"].get("X-Request-ID"), TRACE_ID)


class TestJourneyForwarding(unittest.TestCase):
"""Action-journey forwarding on SDK calls (campus#828).

When the host request carries an action journey (stashed as
flask.g.journey_id by the journeys middleware), SDK calls forward it
as X-Journey-ID so child services' spans join the same journey.
"""

def test_journey_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.journey_id = "uid-journey-abc123"
client.get("/ping")

self.assertEqual(
captured["headers"].get("X-Journey-ID"), "uid-journey-abc123"
)
self.assertEqual(captured["headers"].get("X-Request-ID"), TRACE_ID)

def test_no_journey_no_header(self):
"""A traced request without a journey emits no journey 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-Journey-ID", captured["headers"])

def test_journey_header_outside_trace_context(self):
"""A journey without an active span still forwards (the journeys
middleware can adopt journeys on hosts where span tracing is off;
the trace headers are simply absent)."""
app = flask.Flask(__name__)
client = _make_client()
captured = _capture_send(client)

with app.test_request_context("/"):
flask.g.journey_id = "uid-journey-abc123"
client.get("/ping")

self.assertEqual(
captured["headers"].get("X-Journey-ID"), "uid-journey-abc123"
)
self.assertNotIn("X-Request-ID", captured["headers"])


if __name__ == "__main__":
unittest.main()
Loading