Skip to content
Merged
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
241 changes: 241 additions & 0 deletions tests/integration/test_otel_trace_propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"""Integration tests for OpenTelemetry trace context propagation.

Verifies that trace context is correctly propagated across service
boundaries and that spans share trace IDs with correct parent-child
relationships when flowing through the query endpoint and its
downstream components.
"""

# pylint: disable=protected-access

from collections.abc import Generator

import pytest
from fastapi import Request
from opentelemetry import context as otel_context
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator

from app.endpoints.query import query_endpoint_handler
from authentication.interface import AuthTuple
from models.api.requests import QueryRequest

KNOWN_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
KNOWN_PARENT_SPAN_ID = "00f067aa0ba902b7"
TRACEPARENT = f"00-{KNOWN_TRACE_ID}-{KNOWN_PARENT_SPAN_ID}-01"


@pytest.fixture(name="otel_collector", scope="module")
def otel_collector_fixture() -> Generator[InMemorySpanExporter, None, None]:
"""Install a global TracerProvider backed by an InMemorySpanExporter.

Module-scoped so that every module-level ``trace.get_tracer(__name__)``
proxy resolves to the same real tracer across all tests in this file.
Shutting down the provider between tests would invalidate the cached
proxy and silently drop spans.

Why we reset ``_TRACER_PROVIDER_SET_ONCE`` instead of using
``mock.patch("opentelemetry.trace.get_tracer_provider")``:
``ProxyTracer._tracer`` reads the module-level ``_TRACER_PROVIDER``
variable directly — it never calls ``get_tracer_provider()``.
Patching the function leaves that variable ``None``, so the proxy
falls back to a noop tracer and no spans are captured.
``set_tracer_provider()`` is the only way to populate the variable,
and the one-shot guard must be reset to allow re-installation.
"""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))

trace._TRACER_PROVIDER_SET_ONCE._done = False
trace._TRACER_PROVIDER = None
trace.set_tracer_provider(provider)

yield exporter

provider.shutdown()
trace._TRACER_PROVIDER_SET_ONCE._done = False
trace._TRACER_PROVIDER = None
Comment on lines +55 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline tests/integration/test_otel_trace_propagation.py --items all --type function

rg -n -C 4 \
  '(_TRACER_PROVIDER|_TRACER_PROVIDER_SET_ONCE|set_tracer_provider|get_tracer\()' \
  --glob '*.py'

Repository: lightspeed-core/lightspeed-stack

Length of output: 716


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- tests/integration/test_otel_trace_propagation.py ---'
cat -n tests/integration/test_otel_trace_propagation.py

printf '%s\n' '--- OpenTelemetry provider-state references ---'
rg -n -C 3 \
  '_TRACER_PROVIDER|_TRACER_PROVIDER_SET_ONCE|set_tracer_provider|get_tracer\(' \
  . \
  -g '*.py' \
  -g 'pyproject.toml' \
  -g 'requirements*.txt' \
  -g 'poetry.lock' \
  -g 'uv.lock' \
  -g 'Pipfile*' || true

printf '%s\n' '--- project dependency declarations ---'
rg -n -C 2 'opentelemetry' pyproject.toml requirements*.txt poetry.lock uv.lock Pipfile* 2>/dev/null || true

Repository: lightspeed-core/lightspeed-stack

Length of output: 35579


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n tests/integration/test_otel_trace_propagation.py

rg -n -C 3 \
  '_TRACER_PROVIDER|_TRACER_PROVIDER_SET_ONCE|set_tracer_provider|get_tracer\(' \
  . -g '*.py' -g 'pyproject.toml' -g 'requirements*.txt' -g 'poetry.lock' -g 'uv.lock' -g 'Pipfile*' || true

rg -n -C 2 'opentelemetry' \
  pyproject.toml requirements*.txt poetry.lock uv.lock Pipfile* 2>/dev/null || true

Repository: lightspeed-core/lightspeed-stack

Length of output: 35434


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import importlib.util
import inspect

spec = importlib.util.find_spec("opentelemetry.trace")
print("opentelemetry.trace available:", spec is not None)
if spec is not None:
    from opentelemetry import trace
    from opentelemetry.trace import ProxyTracer

    print("trace module:", trace.__file__)
    print("ProxyTracer source:")
    print(inspect.getsource(ProxyTracer))
    print("set_tracer_provider source:")
    print(inspect.getsource(trace.set_tracer_provider))
    print("get_tracer_provider source:")
    print(inspect.getsource(trace.get_tracer_provider))
PY

printf '%s\n' '--- test ordering and global fixture usage ---'
rg -n -C 3 \
  'test_otel_trace_propagation|otel_collector|TracerProvider\(|set_tracer_provider\(|shutdown\(' \
  tests conftest.py src 2>/dev/null || true

Repository: lightspeed-core/lightspeed-stack

Length of output: 427


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import importlib.util
import inspect

spec = importlib.util.find_spec("opentelemetry.trace")
print("opentelemetry.trace available:", spec is not None)

if spec is not None:
    from opentelemetry import trace
    from opentelemetry.trace import ProxyTracer

    print("trace module:", trace.__file__)
    print(inspect.getsource(ProxyTracer))
    print(inspect.getsource(trace.set_tracer_provider))
    print(inspect.getsource(trace.get_tracer_provider))
PY

rg -n -C 3 \
  'test_otel_trace_propagation|otel_collector|TracerProvider\(|set_tracer_provider\(|shutdown\(' \
  tests conftest.py src 2>/dev/null || true

Repository: lightspeed-core/lightspeed-stack

Length of output: 427


🌐 Web query:

OpenTelemetry Python 1.44.0 trace.py ProxyTracer _TRACER_PROVIDER _TRACER_PROVIDER_SET_ONCE set_tracer_provider source

💡 Result:

In OpenTelemetry Python (including version 1.44.0), the components you mentioned are part of the global tracing API defined in the opentelemetry.trace module [1][2]. These mechanisms work together to manage the global TracerProvider and ensure that tracers can be requested even before a provider is fully initialized [2][3]. ProxyTracer and ProxyTracerProvider The ProxyTracerProvider acts as a placeholder when no global TracerProvider has been configured [2]. When an application calls get_tracer, it initially returns a ProxyTracer [2]. This ProxyTracer holds a reference to the eventual "real" tracer [1][3]. When a span is started through a ProxyTracer, the tracer lazily retrieves or initializes the real tracer from the global _TRACER_PROVIDER once it is set [1][2]. _TRACER_PROVIDER and _TRACER_PROVIDER_SET_ONCE _TRACER_PROVIDER is a module-level variable that holds the actual, user-configured TracerProvider [1][2]. The _TRACER_PROVIDER_SET_ONCE object is typically a synchronization primitive (often a threading lock or a similar mechanism) used to ensure that the global provider can only be configured once, preventing multiple conflicting initializations [2][4]. set_tracer_provider The function set_tracer_provider is the public API used to assign a concrete TracerProvider to the global _TRACER_PROVIDER [2][5]. When this function is called: 1. It acquires the lock (controlled by _TRACER_PROVIDER_SET_ONCE) to verify if a provider has already been set [2][4]. 2. If already set, it may raise a warning or ignore subsequent calls, ensuring the global state remains consistent [6]. 3. Once set, future requests to get_tracer will return tracers directly from the newly configured global provider rather than returning new ProxyTracers [2]. This design allows for decoupling: libraries can request tracers at import time (receiving a ProxyTracer), and the application can inject the actual tracing implementation (e.g., SDK) later during startup [2][5].

Citations:


🌐 Web query:

site:github.com/open-telemetry/opentelemetry-python "class ProxyTracer" "_real_tracer" "get_tracer_provider"

💡 Result:

In the OpenTelemetry Python API, ProxyTracer and ProxyTracerProvider are internal components used to manage the global tracer provider lifecycle [1][2]. When you call get_tracer_provider, if no global TracerProvider has been initialized or set, the library returns a ProxyTracerProvider instead of a concrete implementation [1][2]. The ProxyTracer acts as a placeholder for a real Tracer [1][2]. It maintains an internal attribute, self._real_tracer, which is initially set to None [1][2]. When an operation is performed using the proxy, it checks the global _TRACER_PROVIDER [1][2]. If a provider has been set since the proxy was created, the proxy uses that provider to retrieve a real Tracer (the _real_tracer) and subsequently delegates calls to it [1][2]. If no global provider is available, it defaults to a NoOpTracer to ensure the application continues to function without errors [1][2]. This mechanism allows instrumentation code to acquire tracers before the global SDK configuration is fully loaded or set in the application's lifecycle [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

url='https://raw.githubusercontent.com/open-telemetry/opentelemetry-python/v1.44.0/opentelemetry-api/src/opentelemetry/trace/__init__.py'
curl -fsSL "$url" |
  rg -n -A 45 -B 8 \
    'class ProxyTracer|def set_tracer_provider|_TRACER_PROVIDER_SET_ONCE|def get_tracer_provider'

Repository: lightspeed-core/lightspeed-stack

Length of output: 8156


Restore OpenTelemetry global state in a finally block.

Save _TRACER_PROVIDER and _TRACER_PROVIDER_SET_ONCE._done before installation. Shut down only the test provider, then restore both values. ProxyTracer caches the test provider's tracer, so later tests can emit through a shut-down provider after this fixture sets the global provider to None.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_otel_trace_propagation.py` around lines 55 - 63,
Update the fixture around the provider installation and yield to save the
original values of trace._TRACER_PROVIDER and
trace._TRACER_PROVIDER_SET_ONCE._done, then use a finally block to shut down
only the test provider and restore both saved values. Keep the test provider
setup intact while ensuring cleanup runs even when the test fails.



@pytest.fixture(autouse=True)
def _clear_spans(otel_collector: InMemorySpanExporter) -> None:
"""Clear collected spans before each test."""
otel_collector.clear()


def _inject_w3c_context(traceparent: str) -> object:
"""Extract a W3C traceparent header into OTel context and attach it.

Parameters:
traceparent: W3C Trace Context header value.

Returns:
Context token to pass to ``otel_context.detach``.
"""
ctx = TraceContextTextMapPropagator().extract({"traceparent": traceparent})
return otel_context.attach(ctx)
Comment on lines +72 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Test W3C extraction through the HTTP request path.

_inject_w3c_context sets the current process context before the direct handler call. It does not send TRACEPARENT in Request headers. It bypasses FastAPI instrumentation and HTTP header extraction.

A missing or broken extractor can pass test_incoming_trace_context_is_continued and test_root_span_is_child_of_incoming_parent. Send the header through the mounted ASGI route, then assert the emitted root span context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_otel_trace_propagation.py` around lines 72 - 82,
Update the integration tests to propagate W3C context through the mounted
ASGI/HTTP route instead of calling _inject_w3c_context before the handler. Send
the TRACEPARENT header on the Request, remove direct context attachment from
these scenarios, and assert the emitted root span context retains the incoming
parent trace ID and span ID.



# ============================================================================
# Tests
# ============================================================================


@pytest.mark.asyncio
@pytest.mark.usefixtures("test_config", "mock_ogx_client", "mock_query_agent")
async def test_incoming_trace_context_is_continued(
mock_request_with_auth: Request,
test_auth: AuthTuple,
otel_collector: InMemorySpanExporter,
) -> None:
"""Spans continue the trace ID received in a W3C traceparent header."""
token = _inject_w3c_context(TRACEPARENT)
try:
await query_endpoint_handler(
request=mock_request_with_auth,
query_request=QueryRequest( # pyright: ignore[reportCallIssue]
query="What is Ansible?"
),
auth=test_auth,
mcp_headers={},
)
finally:
otel_context.detach(token) # pyright: ignore[reportArgumentType]

spans = otel_collector.get_finished_spans()
assert len(spans) > 0, "Expected at least one span"

expected_trace_id = int(KNOWN_TRACE_ID, 16)
for span in spans:
assert span.context is not None
assert span.context.trace_id == expected_trace_id, (
f"Span {span.name!r} has trace_id "
f"{span.context.trace_id:#034x}, expected {expected_trace_id:#034x}"
)


@pytest.mark.asyncio
@pytest.mark.usefixtures("test_config", "mock_ogx_client", "mock_query_agent")
async def test_root_span_is_child_of_incoming_parent(
mock_request_with_auth: Request,
test_auth: AuthTuple,
otel_collector: InMemorySpanExporter,
) -> None:
"""The endpoint root span's parent points to the incoming span ID."""
token = _inject_w3c_context(TRACEPARENT)
try:
await query_endpoint_handler(
request=mock_request_with_auth,
query_request=QueryRequest( # pyright: ignore[reportCallIssue]
query="What is Ansible?"
),
auth=test_auth,
mcp_headers={},
)
finally:
otel_context.detach(token) # pyright: ignore[reportArgumentType]

spans = otel_collector.get_finished_spans()
root_spans = [s for s in spans if s.name == "query.handle_request"]
assert len(root_spans) == 1

root = root_spans[0]
assert (
root.parent is not None
), "Root span should be a child of the incoming context"
assert root.parent.span_id == int(KNOWN_PARENT_SPAN_ID, 16)


@pytest.mark.asyncio
@pytest.mark.usefixtures("test_config", "mock_ogx_client", "mock_query_agent")
async def test_spans_across_components_share_trace_id(
mock_request_with_auth: Request,
test_auth: AuthTuple,
otel_collector: InMemorySpanExporter,
) -> None:
"""All spans emitted during a single request share the same trace ID."""
await query_endpoint_handler(
request=mock_request_with_auth,
query_request=QueryRequest( # pyright: ignore[reportCallIssue]
query="What is Ansible?"
),
auth=test_auth,
mcp_headers={},
)

spans = otel_collector.get_finished_spans()
assert len(spans) > 1, "Expected spans from multiple components"

trace_ids = {span.context.trace_id for span in spans if span.context is not None}
assert (
len(trace_ids) == 1
), f"All spans must share one trace ID, got {len(trace_ids)}"


@pytest.mark.asyncio
@pytest.mark.usefixtures("test_config", "mock_ogx_client", "mock_query_agent")
async def test_parent_child_relationships_preserved(
mock_request_with_auth: Request,
test_auth: AuthTuple,
otel_collector: InMemorySpanExporter,
) -> None:
"""Child spans (quota, shield, RAG, inference) are parented to the root span."""
await query_endpoint_handler(
request=mock_request_with_auth,
query_request=QueryRequest( # pyright: ignore[reportCallIssue]
query="What is Ansible?"
),
auth=test_auth,
mcp_headers={},
)

spans = otel_collector.get_finished_spans()

root_spans = [s for s in spans if s.name == "query.handle_request"]
assert len(root_spans) == 1
root = root_spans[0]
assert root.context is not None

child_spans = [s for s in spans if s.name != "query.handle_request"]
assert len(child_spans) >= 1, "Expected at least one child span"

for child in child_spans:
assert child.parent is not None, f"Span {child.name!r} should have a parent"
assert (
child.parent.span_id == root.context.span_id
), f"Span {child.name!r} should be parented to query.handle_request"


@pytest.mark.asyncio
@pytest.mark.usefixtures("test_config", "mock_ogx_client", "mock_query_agent")
async def test_expected_child_spans_are_emitted(
mock_request_with_auth: Request,
test_auth: AuthTuple,
otel_collector: InMemorySpanExporter,
) -> None:
"""The query flow emits the expected set of child spans."""
await query_endpoint_handler(
request=mock_request_with_auth,
query_request=QueryRequest( # pyright: ignore[reportCallIssue]
query="What is Ansible?"
),
auth=test_auth,
mcp_headers={},
)

span_names = {s.name for s in otel_collector.get_finished_spans()}

expected = {
"query.handle_request",
"quota.check",
"shield.moderate",
"llm.inference",
}
missing = expected - span_names
assert not missing, f"Missing expected spans: {missing}"
Loading