From 242e9e043c48b8b3a7802fda0db6ee6fc16bb4c4 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 6 Oct 2026 07:24:46 +0800 Subject: [PATCH] feat(tracing): forward the action journey on SDK calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit propagation_headers() gains X-Journey-ID forwarding (campus#828): when the host request carries an action journey (the journeys middleware stashes it as flask.g.journey_id), outbound SDK calls forward it so the receiving service's span joins the same journey. Trace headers and the journey header are independent — trace headers require span state on flask.g, the journey header only requires an active journey, so journeys work on hosts even where span tracing is off. 3 new tests; suite 249 OK. --- campus_python/tracing.py | 38 ++++++++++++++----- tests/unit/test_trace_propagation.py | 55 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/campus_python/tracing.py b/campus_python/tracing.py index 23c1222..cf9afb3 100644 --- a/campus_python/tracing.py +++ b/campus_python/tracing.py @@ -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" @@ -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: diff --git a/tests/unit/test_trace_propagation.py b/tests/unit/test_trace_propagation.py index cbbd78c..e7e3ecc 100644 --- a/tests/unit/test_trace_propagation.py +++ b/tests/unit/test_trace_propagation.py @@ -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()