Skip to content

Commit 3f58339

Browse files
authored
poc auto-disable openai instrumentation in LiteLLMCallback (#177)
* poc auto-disable openai instrumentation in LiteLLMCallback * remove kwarg, fix tests * fix typehint in conftest
1 parent 9c5bcbd commit 3f58339

File tree

7 files changed

+108
-65
lines changed

7 files changed

+108
-65
lines changed

src/lmnr/opentelemetry_lib/litellm/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
from lmnr.opentelemetry_lib.utils.package_check import is_package_installed
1212
from lmnr.sdk.log import get_default_logger
1313

14+
from lmnr.opentelemetry_lib.opentelemetry.instrumentation.openai import (
15+
OpenAIInstrumentor,
16+
)
17+
1418
logger = get_default_logger(__name__)
1519

1620
SUPPORTED_CALL_TYPES = ["completion", "acompletion"]
@@ -41,6 +45,17 @@ def __init__(self, **kwargs):
4145
if not hasattr(TracerWrapper, "instance") or TracerWrapper.instance is None:
4246
raise ValueError("Laminar must be initialized before LiteLLM callback")
4347

48+
if is_package_installed("openai"):
49+
openai_instrumentor = OpenAIInstrumentor()
50+
if (
51+
openai_instrumentor
52+
and openai_instrumentor.is_instrumented_by_opentelemetry
53+
):
54+
logger.info(
55+
"Disabling OpenTelemetry instrumentation for OpenAI to avoid double-instrumentation of LiteLLM."
56+
)
57+
openai_instrumentor.uninstrument()
58+
4459
def _get_tracer(self) -> Tracer:
4560
if not hasattr(TracerWrapper, "instance") or TracerWrapper.instance is None:
4661
raise ValueError("Laminar must be initialized before LiteLLM callback")

src/lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from opentelemetry._events import get_event_logger
44
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
5+
6+
from lmnr.sdk.log import get_default_logger
57
from ..shared.chat_wrappers import (
68
achat_wrapper,
79
chat_wrapper,
@@ -44,6 +46,7 @@
4446

4547

4648
_instruments = ("openai >= 1.0.0",)
49+
logger = get_default_logger(__name__)
4750

4851

4952
class OpenAIV1Instrumentor(BaseInstrumentor):
@@ -63,7 +66,8 @@ def _try_wrap(self, module, function, wrapper):
6366
"""
6467
try:
6568
wrap_function_wrapper(module, function, wrapper)
66-
except (AttributeError, ModuleNotFoundError):
69+
except (AttributeError, ModuleNotFoundError, ImportError):
70+
logger.debug(f"Failed to wrap {module}.{function}")
6771
pass
6872

6973
def _instrument(self, **kwargs):
@@ -331,28 +335,34 @@ def _instrument(self, **kwargs):
331335
)
332336

333337
def _uninstrument(self, **kwargs):
334-
unwrap("openai.resources.chat.completions", "Completions.create")
335-
unwrap("openai.resources.completions", "Completions.create")
336-
unwrap("openai.resources.embeddings", "Embeddings.create")
337-
unwrap("openai.resources.chat.completions", "AsyncCompletions.create")
338-
unwrap("openai.resources.completions", "AsyncCompletions.create")
339-
unwrap("openai.resources.embeddings", "AsyncEmbeddings.create")
340-
unwrap("openai.resources.images", "Images.generate")
338+
self.try_unwrap("openai.resources.chat.completions.Completions", "create")
339+
self.try_unwrap("openai.resources.completions.Completions", "create")
340+
self.try_unwrap("openai.resources.embeddings.Embeddings", "create")
341+
self.try_unwrap("openai.resources.chat.completions.AsyncCompletions", "create")
342+
self.try_unwrap("openai.resources.completions.AsyncCompletions", "create")
343+
self.try_unwrap("openai.resources.embeddings.AsyncEmbeddings", "create")
344+
self.try_unwrap("openai.resources.images.Images", "generate")
345+
self.try_unwrap("openai.resources.chat.completions.Completions", "parse")
346+
self.try_unwrap("openai.resources.chat.completions.AsyncCompletions", "parse")
347+
self.try_unwrap("openai.resources.beta.assistants.Assistants", "create")
348+
self.try_unwrap("openai.resources.beta.chat.completions.Completions", "parse")
349+
self.try_unwrap(
350+
"openai.resources.beta.chat.completions.AsyncCompletions", "parse"
351+
)
352+
self.try_unwrap("openai.resources.beta.threads.runs.Runs", "create")
353+
self.try_unwrap("openai.resources.beta.threads.runs.Runs", "retrieve")
354+
self.try_unwrap("openai.resources.beta.threads.runs.Runs", "create_and_stream")
355+
self.try_unwrap("openai.resources.beta.threads.messages.Messages", "list")
356+
self.try_unwrap("openai.resources.responses.Responses", "create")
357+
self.try_unwrap("openai.resources.responses.Responses", "retrieve")
358+
self.try_unwrap("openai.resources.responses.Responses", "cancel")
359+
self.try_unwrap("openai.resources.responses.AsyncResponses", "create")
360+
self.try_unwrap("openai.resources.responses.AsyncResponses", "retrieve")
361+
self.try_unwrap("openai.resources.responses.AsyncResponses", "cancel")
341362

342-
# Beta APIs may not be available consistently in all versions
363+
def try_unwrap(self, module, function):
343364
try:
344-
unwrap("openai.resources.beta.assistants", "Assistants.create")
345-
unwrap("openai.resources.beta.chat.completions", "Completions.parse")
346-
unwrap("openai.resources.beta.chat.completions", "AsyncCompletions.parse")
347-
unwrap("openai.resources.beta.threads.runs", "Runs.create")
348-
unwrap("openai.resources.beta.threads.runs", "Runs.retrieve")
349-
unwrap("openai.resources.beta.threads.runs", "Runs.create_and_stream")
350-
unwrap("openai.resources.beta.threads.messages", "Messages.list")
351-
unwrap("openai.resources.responses", "Responses.create")
352-
unwrap("openai.resources.responses", "Responses.retrieve")
353-
unwrap("openai.resources.responses", "Responses.cancel")
354-
unwrap("openai.resources.responses", "AsyncResponses.create")
355-
unwrap("openai.resources.responses", "AsyncResponses.retrieve")
356-
unwrap("openai.resources.responses", "AsyncResponses.cancel")
357-
except ImportError:
365+
unwrap(module, function)
366+
except (AttributeError, ModuleNotFoundError, ImportError):
367+
logger.debug(f"Failed to unwrap {module}.{function}")
358368
pass

tests/conftest.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Generator
12
import pytest
23
from unittest.mock import patch
34
from lmnr import Laminar
@@ -37,9 +38,30 @@ def mock_tracermanager_init(*args, **kwargs):
3738
return exporter
3839

3940

40-
@pytest.fixture(scope="session")
41-
def litellm_callback() -> LaminarLiteLLMCallback:
42-
return LaminarLiteLLMCallback()
41+
@pytest.fixture(scope="function")
42+
def litellm_callback() -> Generator[LaminarLiteLLMCallback, None, None]:
43+
from lmnr.opentelemetry_lib.opentelemetry.instrumentation.openai import (
44+
OpenAIInstrumentor,
45+
)
46+
47+
# Check if OpenAI was instrumented before we create the LiteLLM callback
48+
instrumentor = OpenAIInstrumentor()
49+
was_instrumented = instrumentor.is_instrumented_by_opentelemetry
50+
51+
# Create the callback (this will uninstrument OpenAI if it was instrumented)
52+
callback = LaminarLiteLLMCallback()
53+
54+
yield callback
55+
56+
# Re-instrument OpenAI if it was originally instrumented
57+
if was_instrumented and not instrumentor.is_instrumented_by_opentelemetry:
58+
# Re-instrument with the same settings as the global initialization
59+
from lmnr.opentelemetry_lib.tracing import TracerWrapper
60+
61+
if hasattr(TracerWrapper, "instance") and TracerWrapper.instance is not None:
62+
instrumentor.instrument(
63+
tracer_provider=TracerWrapper.instance._tracer_provider
64+
)
4365

4466

4567
@pytest.fixture(scope="function", autouse=True)

tests/test_instrumentations/test_openai/conftest.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def fixture_meter_provider(reader):
115115
return meter_provider
116116

117117

118-
@pytest.fixture(scope="session")
118+
@pytest.fixture(scope="function")
119119
def instrument_legacy(reader, tracer_provider, meter_provider):
120120
async def upload_base64_image(*args):
121121
return "/some/url"
@@ -125,14 +125,18 @@ async def upload_base64_image(*args):
125125
enrich_token_usage=True,
126126
upload_base64_image=upload_base64_image,
127127
)
128-
instrumentor.instrument(
129-
tracer_provider=tracer_provider,
130-
meter_provider=meter_provider,
131-
)
128+
was_already_instrumented = instrumentor.is_instrumented_by_opentelemetry
129+
if not was_already_instrumented:
130+
instrumentor.instrument(
131+
tracer_provider=tracer_provider,
132+
meter_provider=meter_provider,
133+
)
132134

133135
yield instrumentor
134136

135-
instrumentor.uninstrument()
137+
# Only uninstrument if we instrumented it ourselves
138+
if not was_already_instrumented and instrumentor.is_instrumented_by_opentelemetry:
139+
instrumentor.uninstrument()
136140

137141

138142
@pytest.fixture(scope="function")
@@ -152,7 +156,6 @@ def instrument_with_content(
152156
Config.use_legacy_attributes = True
153157
Config.event_logger = None
154158
os.environ.pop(LMNR_TRACE_CONTENT, None)
155-
instrumentor.uninstrument()
156159

157160

158161
@pytest.fixture(scope="function")
@@ -172,7 +175,6 @@ def instrument_with_no_content(
172175
Config.use_legacy_attributes = True
173176
Config.event_logger = None
174177
os.environ.pop(LMNR_TRACE_CONTENT, None)
175-
instrumentor.uninstrument()
176178

177179

178180
@pytest.fixture(scope="module")

tests/test_instrumentations/test_openai/traces/test_chat.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,6 @@ def test_with_asyncio_run(
945945

946946

947947
@pytest.mark.vcr
948-
@pytest.mark.asyncio
949948
def test_with_asyncio_run_with_events_with_content(
950949
instrument_with_content, span_exporter, log_exporter, async_openai_client
951950
):
@@ -994,7 +993,6 @@ def test_with_asyncio_run_with_events_with_content(
994993

995994

996995
@pytest.mark.vcr
997-
@pytest.mark.asyncio
998996
def test_with_asyncio_run_with_events_with_no_content(
999997
instrument_with_no_content, span_exporter, log_exporter, async_openai_client
1000998
):
@@ -1301,7 +1299,7 @@ def assert_message_in_logs(log: LogData, event_name: str, expected_content: dict
13011299

13021300

13031301
@pytest.mark.vcr
1304-
def test_chat_history_message_dict(span_exporter, openai_client):
1302+
def test_chat_history_message_dict(instrument_legacy, span_exporter, openai_client):
13051303
first_user_message = {
13061304
"role": "user",
13071305
"content": "Generate a random noun in Korean. Respond with just that word.",
@@ -1371,7 +1369,7 @@ def test_chat_history_message_dict(span_exporter, openai_client):
13711369

13721370

13731371
@pytest.mark.vcr
1374-
def test_chat_history_message_pydantic(span_exporter, openai_client):
1372+
def test_chat_history_message_pydantic(instrument_legacy, span_exporter, openai_client):
13751373
first_user_message = {
13761374
"role": "user",
13771375
"content": "Generate a random noun in Korean. Respond with just that word.",

tests/test_litellm_openai.py

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ def test_litellm_openai_basic(
3434
time.sleep(SLEEP_TO_FLUSH_SECONDS)
3535

3636
spans = span_exporter.get_finished_spans()
37-
assert len(spans) == 2
38-
span = [s for s in spans if s.name == "litellm.completion"][0]
37+
assert len(spans) == 1
38+
span = spans[0]
3939
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
4040
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
4141
assert span.attributes["gen_ai.response.id"] == response.id
@@ -79,8 +79,8 @@ def test_litellm_openai_text_block(
7979
time.sleep(SLEEP_TO_FLUSH_SECONDS)
8080

8181
spans = span_exporter.get_finished_spans()
82-
assert len(spans) == 2
83-
span = [s for s in spans if s.name == "litellm.completion"][0]
82+
assert len(spans) == 1
83+
span = spans[0]
8484
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
8585
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
8686
assert span.attributes["gen_ai.response.id"] == response.id
@@ -124,8 +124,8 @@ def test_litellm_openai_with_streaming(
124124
time.sleep(SLEEP_TO_FLUSH_SECONDS)
125125

126126
spans = span_exporter.get_finished_spans()
127-
assert len(spans) == 2
128-
span = [s for s in spans if s.name == "litellm.completion"][0]
127+
assert len(spans) == 1
128+
span = spans[0]
129129
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
130130
assert span.attributes["gen_ai.usage.input_tokens"] == 14
131131
assert span.attributes["gen_ai.usage.output_tokens"] == 7
@@ -178,12 +178,9 @@ def test_litellm_openai_with_chat_history(
178178
time.sleep(SLEEP_TO_FLUSH_SECONDS)
179179

180180
spans = span_exporter.get_finished_spans()
181-
assert len(spans) == 4
182-
inner_spans = [s for s in spans if s.name == "litellm.completion"]
183-
assert len(inner_spans) == 2
184-
inner_spans = sorted(inner_spans, key=lambda s: s.start_time)
185-
first_span = sorted(inner_spans, key=lambda s: s.start_time)[0]
186-
second_span = sorted(inner_spans, key=lambda s: s.start_time)[1]
181+
assert len(spans) == 2
182+
first_span = sorted(spans, key=lambda s: s.start_time)[0]
183+
second_span = sorted(spans, key=lambda s: s.start_time)[1]
187184
assert first_span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
188185
assert second_span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
189186

@@ -258,8 +255,8 @@ def test_litellm_openai_with_image_base64(
258255
time.sleep(SLEEP_TO_FLUSH_SECONDS)
259256

260257
spans = span_exporter.get_finished_spans()
261-
assert len(spans) == 2
262-
span = [s for s in spans if s.name == "litellm.completion"][0]
258+
assert len(spans) == 1
259+
span = spans[0]
263260
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
264261
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
265262
assert span.attributes["gen_ai.response.id"] == response.id
@@ -313,8 +310,8 @@ def test_litellm_openai_with_image_url(
313310
time.sleep(SLEEP_TO_FLUSH_SECONDS)
314311

315312
spans = span_exporter.get_finished_spans()
316-
assert len(spans) == 2
317-
span = [s for s in spans if s.name == "litellm.completion"][0]
313+
assert len(spans) == 1
314+
span = spans[0]
318315
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
319316
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
320317
assert span.attributes["gen_ai.response.id"] == response.id
@@ -374,8 +371,8 @@ async def test_async_litellm_openai_with_image_base64(
374371
await asyncio.sleep(SLEEP_TO_FLUSH_SECONDS)
375372

376373
spans = span_exporter.get_finished_spans()
377-
assert len(spans) == 2
378-
span = [s for s in spans if s.name == "litellm.completion"][0]
374+
assert len(spans) == 1
375+
span = spans[0]
379376
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
380377
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
381378
assert span.attributes["gen_ai.response.id"] == response.id
@@ -430,8 +427,8 @@ async def test_async_litellm_openai_with_image_url(
430427
await asyncio.sleep(SLEEP_TO_FLUSH_SECONDS)
431428

432429
spans = span_exporter.get_finished_spans()
433-
assert len(spans) == 2
434-
span = [s for s in spans if s.name == "litellm.completion"][0]
430+
assert len(spans) == 1
431+
span = spans[0]
435432
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
436433
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
437434
assert span.attributes["gen_ai.response.id"] == response.id
@@ -472,8 +469,8 @@ async def test_async_litellm_openai_basic(
472469
await asyncio.sleep(SLEEP_TO_FLUSH_SECONDS)
473470

474471
spans = span_exporter.get_finished_spans()
475-
assert len(spans) == 2
476-
span = [s for s in spans if s.name == "litellm.completion"][0]
472+
assert len(spans) == 1
473+
span = spans[0]
477474
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
478475
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
479476
assert span.attributes["gen_ai.response.id"] == response.id
@@ -518,8 +515,8 @@ async def test_async_litellm_openai_text_block(
518515
await asyncio.sleep(SLEEP_TO_FLUSH_SECONDS)
519516

520517
spans = span_exporter.get_finished_spans()
521-
assert len(spans) == 2
522-
span = [s for s in spans if s.name == "litellm.completion"][0]
518+
assert len(spans) == 1
519+
span = spans[0]
523520
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
524521
assert span.attributes["gen_ai.response.model"] == "gpt-4.1-nano-2025-04-14"
525522
assert span.attributes["gen_ai.response.id"] == response.id
@@ -564,8 +561,8 @@ async def test_async_litellm_openai_with_streaming(
564561
await asyncio.sleep(SLEEP_TO_FLUSH_SECONDS)
565562

566563
spans = span_exporter.get_finished_spans()
567-
assert len(spans) == 2
568-
span = [s for s in spans if s.name == "litellm.completion"][0]
564+
assert len(spans) == 1
565+
span = spans[0]
569566
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
570567
assert span.attributes["gen_ai.usage.input_tokens"] == 14
571568
assert span.attributes["gen_ai.usage.output_tokens"] == 7

tests/test_observe.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from lmnr import Laminar, observe
66
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
77
from opentelemetry import trace
8-
from opentelemetry.trace import INVALID_SPAN_ID
98

109

1110
def test_observe(span_exporter: InMemorySpanExporter):

0 commit comments

Comments
 (0)