-
Notifications
You must be signed in to change notification settings - Fork 98
LCORE-1823: Add integration tests for OpenTelemetry trace context propagation #2422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
||
| @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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
A missing or broken extractor can pass 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| # ============================================================================ | ||
| # 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}" | ||
There was a problem hiding this comment.
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:
Repository: lightspeed-core/lightspeed-stack
Length of output: 716
🏁 Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 35579
🏁 Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 35434
🏁 Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 427
🏁 Script executed:
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:
set_tracer_provider()open-telemetry/opentelemetry-python#2181🌐 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:
Repository: lightspeed-core/lightspeed-stack
Length of output: 8156
Restore OpenTelemetry global state in a
finallyblock.Save
_TRACER_PROVIDERand_TRACER_PROVIDER_SET_ONCE._donebefore installation. Shut down only the test provider, then restore both values.ProxyTracercaches the test provider's tracer, so later tests can emit through a shut-down provider after this fixture sets the global provider toNone.🤖 Prompt for AI Agents