Skip to content

Tracing

Every SDK call propagates audit trace context — trace, journey, and device headers — so the Campus Audit waterfall shows which host route (or which process boot) fired each campus call. The machinery lives in campus_python.tracing and is on by default; hosts only touch it to install providers or override journey attribution.

In-request calls

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 land as child spans of it: the middleware's propagation module installs a request-context provider via set_request_context_provider(), and its output (span/journey/device headers) is attached verbatim to outbound SDK calls.

The SDK itself is framework-free: with no provider installed, calls are treated as out-of-request and carry only the boot journey below.

Boot journeys

Every SDK call is a user request, whether that user is human or agent. Calls made outside any request context — startup, background threads — carry a process journey (one uid-journey-... per process boot) plus a process device tag so boot spans attribute to the deploy that produced them:

  • Device id defaults to RAILWAY_DEPLOYMENT_ID when present.
  • Inside a request, the caller's own journey always wins; the strict fallback only fills the out-of-request gap.

Overrides:

import campus_python.tracing as tracing

tracing.set_process_journey("uid-journey-migration-run")   # attribute to a named journey
tracing.set_process_device("worker-3")                     # override the device tag
tracing.disable_process_journey()                          # or suppress entirely

CAMPUS_PROCESS_JOURNEY=off (also false/0/no) suppresses the boot journey via the environment — useful for tests and operators. propagation_headers() returns what the next SDK call would send, which is handy for assertions in tests.

Instrumenting a custom transport

The propagation headers are attached by the default CampusRequest transport. If you bring your own JsonClient (see Custom clients), call instrument_requests_session(session) on the underlying requests.Session to get the same header propagation, or attach propagation_headers() to each request yourself.

Session-id resolution (login/auth-session lanes) is a separate injectable provider — see Authentication › User sessions and set_session_id_provider().

Reference

campus_python.tracing

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.

In-request headers come from the installed request-context provider (set_request_context_provider) — flask_campus installs one that reads the flask.g state campus.audit.middleware stashes. The SDK itself is framework-free: with no provider installed, calls are treated as out-of-request and carry only the boot journey below.

Boot journeys (#114): every SDK call is a user request, whether that user is human or agent — so calls made outside any request context (startup, background threads) carry a process journey, one uid-journey-… per process boot, plus a process device tag (default: RAILWAY_DEPLOYMENT_ID) so boot spans attribute to the deploy that produced them. Inside a request the caller's own journey always wins; strict fallback only fills the out-of-request gap (campus-api-python#114 ruling, 2026-10-21). Set CAMPUS_PROCESS_JOURNEY=off to suppress (tests, operators); set_process_journey()/set_process_device() override in code.

PROCESS_JOURNEY_ENV = 'CAMPUS_PROCESS_JOURNEY' module-attribute

PROCESS_DEVICE_ENV = 'RAILWAY_DEPLOYMENT_ID' module-attribute

set_request_context_provider(provider: RequestContextProvider | None) -> None

Install (or clear) the request-context propagation provider.

When installed, propagation_headers() takes the provider's output verbatim for in-request calls (the provider owns the header contract, including device gap-fill) and applies the process boot journey only when the provider reports no active request context (returns None). Pass None to revert to the built-in flask reader.

Source code in campus_python/tracing.py
210
211
212
213
214
215
216
217
218
219
220
221
222
def set_request_context_provider(
        provider: RequestContextProvider | None,
) -> None:
    """Install (or clear) the request-context propagation provider.

    When installed, propagation_headers() takes the provider's output
    verbatim for in-request calls (the provider owns the header
    contract, including device gap-fill) and applies the process boot
    journey only when the provider reports no active request context
    (returns None). Pass None to revert to the built-in flask reader.
    """
    global _request_context_provider
    _request_context_provider = provider

set_process_journey(journey_id: str | None = None, *, name: str | None = None) -> str

Set this process's boot journey explicitly (#114).

Overrides CAMPUS_PROCESS_JOURNEY and re-enables journeys after disable_process_journey(). Mints a uid-journey-… when journey_id is omitted (keeping any previously minted/explicit id on later calls); a name labels the journey's cards (forwarded as X-Journey-Name, campus#952).

Returns:

Type Description
str

The process journey id now in effect.

Source code in campus_python/tracing.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def set_process_journey(
        journey_id: str | None = None,
        *,
        name: str | None = None,
) -> str:
    """Set this process's boot journey explicitly (#114).

    Overrides CAMPUS_PROCESS_JOURNEY and re-enables journeys after
    disable_process_journey(). Mints a uid-journey-… when journey_id is
    omitted (keeping any previously minted/explicit id on later calls);
    a name labels the journey's cards (forwarded as X-Journey-Name,
    campus#952).

    Returns:
        The process journey id now in effect.
    """
    with _process_lock:
        if not _process_state["initialized"]:
            _init_process_state()
        _process_state["disabled"] = False
        if _process_state["journey_id"] is None:
            _process_state["journey_id"] = journey_id or _mint_journey_id()
        elif journey_id is not None:
            _process_state["journey_id"] = journey_id
        if name is not None:
            _process_state["journey_name"] = name
        return _process_state["journey_id"]

disable_process_journey() -> None

Suppress the boot journey on out-of-request calls (#114).

For tests and operators who don't want the correlation; the process device tag is unaffected. set_process_journey() re-enables.

Source code in campus_python/tracing.py
159
160
161
162
163
164
165
166
167
168
def disable_process_journey() -> None:
    """Suppress the boot journey on out-of-request calls (#114).

    For tests and operators who don't want the correlation; the process
    device tag is unaffected. set_process_journey() re-enables.
    """
    with _process_lock:
        if not _process_state["initialized"]:
            _init_process_state()
        _process_state["disabled"] = True

set_process_device(device_id: str) -> None

Override this process's device tag (default: RAILWAY_DEPLOYMENT_ID).

Used as a fallback only — a request context with its own stashed device keeps it (campus#825/#837 semantics are unchanged).

Source code in campus_python/tracing.py
171
172
173
174
175
176
177
178
179
180
def set_process_device(device_id: str) -> None:
    """Override this process's device tag (default: RAILWAY_DEPLOYMENT_ID).

    Used as a fallback only — a request context with its own stashed
    device keeps it (campus#825/#837 semantics are unchanged).
    """
    with _process_lock:
        if not _process_state["initialized"]:
            _init_process_state()
        _process_state["device_id"] = device_id

process_device() -> str | None

This process's device tag (default: RAILWAY_DEPLOYMENT_ID).

Public accessor for propagation hosts (flask_campus) implementing device gap-fill: a request that stashed no device of its own may present the process device. Never mints a journey (#114).

Source code in campus_python/tracing.py
 97
 98
 99
100
101
102
103
104
def process_device() -> str | None:
    """This process's device tag (default: RAILWAY_DEPLOYMENT_ID).

    Public accessor for propagation hosts (flask_campus) implementing
    device gap-fill: a request that stashed no device of its own may
    present the process device. Never mints a journey (#114).
    """
    return _process_device()

propagation_headers() -> dict[str, str]

Headers to attach to an outbound SDK call from the active context.

Empty outside a request context until #114 — now: out-of-request calls carry the process boot journey (X-Journey-ID/X-Journey-Name) and the process device (X-Campus-Device, default RAILWAY_DEPLOYMENT_ID), so startup and background calls correlate under one journey per boot. Inside a request, the caller's own journey always wins — a journey-less request stays unjourneyed (strict MVP: the trace waterfall already correlates the chain) — and the process device only fills the gap when the request stashed none. 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), with X-Journey-Name carrying its human label (campus#952); the tracing middleware adopts X-Campus-Device for the child span's device tag when it knows nothing better (campus#837).

The in-request half resolves from the installed request-context provider (set_request_context_provider); with no provider there is no request-context half — every call is treated as out-of-request and carries only the boot journey (the former built-in flask reader was removed in 0.5.0; flask_campus installs the provider).

Source code in campus_python/tracing.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def propagation_headers() -> dict[str, str]:
    """Headers to attach to an outbound SDK call from the active context.

    Empty outside a request context until #114 — now: out-of-request
    calls carry the process boot journey (X-Journey-ID/X-Journey-Name)
    and the process device (X-Campus-Device, default RAILWAY_DEPLOYMENT_ID),
    so startup and background calls correlate under one journey per boot.
    Inside a request, the caller's own journey always wins — a journey-less
    request stays unjourneyed (strict MVP: the trace waterfall already
    correlates the chain) — and the process device only fills the gap
    when the request stashed none. 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), with X-Journey-Name carrying its human label
    (campus#952); the tracing middleware adopts X-Campus-Device for the
    child span's device tag when it knows nothing better (campus#837).

    The in-request half resolves from the installed request-context
    provider (set_request_context_provider); with no provider there is
    no request-context half — every call is treated as out-of-request
    and carries only the boot journey (the former built-in flask
    reader was removed in 0.5.0; flask_campus installs the provider).
    """
    headers: dict[str, str] = {}
    request_headers = (
        _request_context_provider() if _request_context_provider else None
    )

    if request_headers is None:
        # Out of any request context (startup, background threads): the
        # boot journey and process device are the only correlation there is.
        journey_id, journey_name, device_id = _process_snapshot()
        if journey_id:
            headers[JOURNEY_ID_HEADER] = journey_id
        if journey_name:
            headers[JOURNEY_NAME_HEADER] = journey_name
        if device_id:
            headers[DEVICE_ID_HEADER] = device_id
        return headers

    headers.update(request_headers)
    return headers

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. Calls outside a request context (startup, background threads) carry the process boot journey and device (#114); calls inside a request never inherit it (the caller's journey, or none, wins).

Idempotent: re-instrumenting a session is a no-op.

Parameters:

Name Type Description Default
session Session

The requests.Session to instrument.

required

Returns:

Type Description
bool

True if the session was instrumented now, False if already done.

Source code in campus_python/tracing.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
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.
    Calls outside a request context (startup, background threads) carry
    the process boot journey and device (#114); calls inside a request
    never inherit it (the caller's journey, or none, wins).

    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