diff --git a/langfuse/_client/resource_manager.py b/langfuse/_client/resource_manager.py index 08c008234..4cca747ad 100644 --- a/langfuse/_client/resource_manager.py +++ b/langfuse/_client/resource_manager.py @@ -425,6 +425,18 @@ def _init_tracer_provider( release: Optional[str] = None, sample_rate: Optional[float] = None, ) -> TracerProvider: + """Initialize or retrieve a TracerProvider compatible with Langfuse. + + This function handles three scenarios: + 1. No provider set (ProxyTracerProvider): Creates a new SDK TracerProvider and sets it as global + 2. SDK TracerProvider already set: Reuses the existing provider + 3. Non-SDK TracerProvider (e.g., ddtrace's API-only provider): Creates a new SDK TracerProvider + for Langfuse without overriding the global provider to avoid conflicts + + The third case addresses compatibility with libraries like ddtrace that use + opentelemetry-api but not opentelemetry-sdk. These providers don't have the + add_span_processor method that Langfuse requires. + """ environment = environment or os.environ.get(LANGFUSE_TRACING_ENVIRONMENT) release = release or os.environ.get(LANGFUSE_RELEASE) or get_common_release_envs() @@ -437,19 +449,43 @@ def _init_tracer_provider( {k: v for k, v in resource_attributes.items() if v is not None} ) - provider = None - default_provider = cast(TracerProvider, otel_trace_api.get_tracer_provider()) + default_provider = otel_trace_api.get_tracer_provider() - if isinstance(default_provider, otel_trace_api.ProxyTracerProvider): - provider = TracerProvider( - resource=resource, - sampler=TraceIdRatioBased(sample_rate) - if sample_rate is not None and sample_rate < 1 - else None, - ) - otel_trace_api.set_tracer_provider(provider) + # Check if the existing provider is an SDK TracerProvider (has add_span_processor method). + # Some OpenTelemetry integrations (like ddtrace) use API-only TracerProviders that don't + # have the add_span_processor method required by Langfuse. + is_sdk_tracer_provider = isinstance(default_provider, TracerProvider) + is_proxy_tracer_provider = isinstance( + default_provider, otel_trace_api.ProxyTracerProvider + ) + if is_sdk_tracer_provider: + # Reuse the existing SDK TracerProvider + return default_provider + + # Create a new SDK TracerProvider for Langfuse + provider = TracerProvider( + resource=resource, + sampler=TraceIdRatioBased(sample_rate) + if sample_rate is not None and sample_rate < 1 + else None, + ) + + if is_proxy_tracer_provider: + # No provider has been set yet, so we can set ours as the global provider + otel_trace_api.set_tracer_provider(provider) else: - provider = default_provider + # Another non-SDK provider exists (e.g., ddtrace's API-only provider). + # Don't override the global provider to avoid "Overriding of current + # TracerProvider is not allowed" errors. Langfuse will use its own + # provider internally while other integrations keep their own. + langfuse_logger.info( + "Detected an existing OpenTelemetry TracerProvider that is not an SDK TracerProvider " + "(e.g., from ddtrace or another OpenTelemetry integration). Langfuse will create its " + "own internal TracerProvider for tracing. To share a TracerProvider with other " + "libraries, set a global SDK TracerProvider before initializing any OpenTelemetry " + "integrations, or pass a dedicated TracerProvider to Langfuse via the tracer_provider " + "parameter." + ) return provider diff --git a/tests/test_otel.py b/tests/test_otel.py index 89c028c68..4d39e56ab 100644 --- a/tests/test_otel.py +++ b/tests/test_otel.py @@ -3328,3 +3328,127 @@ def test_langfuse_event_update_immutability(self, langfuse_client, caplog): assert result is event parent_span.end() + + +class TestTracerProviderCompatibility(TestOTelBase): + """Tests for TracerProvider compatibility with non-SDK providers like ddtrace.""" + + def test_init_tracer_provider_with_non_sdk_provider(self, monkeypatch, caplog): + """Test that _init_tracer_provider creates a new SDK provider when a non-SDK provider exists. + + This tests the core logic that handles ddtrace-like scenarios where an + API-only TracerProvider (without add_span_processor) is set globally. + """ + import logging + + from opentelemetry import trace as trace_api_module + from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider + from opentelemetry.trace import TracerProvider as APITracerProvider + + from langfuse._client.resource_manager import _init_tracer_provider + + # Create a mock non-SDK provider (like ddtrace's API-only provider) + class MockNonSDKTracerProvider(APITracerProvider): + """A mock TracerProvider without add_span_processor.""" + + def get_tracer( + self, + instrumenting_module_name, + instrumenting_library_version=None, + schema_url=None, + attributes=None, + ): + return trace_api_module.NoOpTracer() + + mock_provider = MockNonSDKTracerProvider() + + # Mock get_tracer_provider to return our non-SDK provider + monkeypatch.setattr( + "langfuse._client.resource_manager.otel_trace_api.get_tracer_provider", + lambda: mock_provider, + ) + + # Track if set_tracer_provider was called + set_provider_calls = [] + + def mock_set_provider(provider): + set_provider_calls.append(provider) + # Don't actually set it to avoid affecting other tests + + monkeypatch.setattr( + "langfuse._client.resource_manager.otel_trace_api.set_tracer_provider", + mock_set_provider, + ) + + # Call _init_tracer_provider + with caplog.at_level(logging.INFO, logger="langfuse"): + provider = _init_tracer_provider() + + # Verify a new SDK TracerProvider was created + assert isinstance(provider, SDKTracerProvider) + assert provider is not mock_provider + + # Verify set_tracer_provider was NOT called (because non-SDK provider exists) + assert len(set_provider_calls) == 0 + + # Verify the info message was logged + assert "not an SDK TracerProvider" in caplog.text + + def test_init_tracer_provider_with_sdk_provider(self, monkeypatch): + """Test that _init_tracer_provider reuses an existing SDK provider.""" + from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider + + from langfuse._client.resource_manager import _init_tracer_provider + + # Create an existing SDK provider + existing_sdk_provider = SDKTracerProvider() + + # Mock get_tracer_provider to return our SDK provider + monkeypatch.setattr( + "langfuse._client.resource_manager.otel_trace_api.get_tracer_provider", + lambda: existing_sdk_provider, + ) + + # Call _init_tracer_provider + provider = _init_tracer_provider() + + # Verify the existing SDK provider is reused + assert provider is existing_sdk_provider + + def test_init_tracer_provider_with_proxy_provider(self, monkeypatch): + """Test that _init_tracer_provider creates and sets a new SDK provider when no provider is set.""" + from opentelemetry import trace as trace_api_module + from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider + + from langfuse._client.resource_manager import _init_tracer_provider + + # Create a ProxyTracerProvider (the default when nothing is set) + proxy_provider = trace_api_module.ProxyTracerProvider() + + # Mock get_tracer_provider to return the proxy provider + monkeypatch.setattr( + "langfuse._client.resource_manager.otel_trace_api.get_tracer_provider", + lambda: proxy_provider, + ) + + # Track if set_tracer_provider was called + set_provider_calls = [] + + def mock_set_provider(provider): + set_provider_calls.append(provider) + + monkeypatch.setattr( + "langfuse._client.resource_manager.otel_trace_api.set_tracer_provider", + mock_set_provider, + ) + + # Call _init_tracer_provider + provider = _init_tracer_provider() + + # Verify a new SDK TracerProvider was created + assert isinstance(provider, SDKTracerProvider) + + # Verify set_tracer_provider WAS called (because only a proxy provider existed) + assert len(set_provider_calls) == 1 + assert set_provider_calls[0] is provider +