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
7 changes: 7 additions & 0 deletions campus_python/json_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
108 changes: 108 additions & 0 deletions campus_python/tracing.py
Original file line number Diff line number Diff line change
@@ -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
130 changes: 130 additions & 0 deletions tests/unit/test_trace_propagation.py
Original file line number Diff line number Diff line change
@@ -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()
Loading