Skip to content

Commit 3b66c84

Browse files
feat(langchain): add OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI env var
Expose the OpenAI span-suppression behaviour as a configurable env var instead of always injecting the suppress_language_model_instrumentation context key. Default is true (backward compatible). Zero-code deployments can set OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI=false together with OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=openai so that openai-v2 is excluded at startup and no runtime suppression is needed. Manual users who want both instrumentors to emit spans simultaneously can also use false without disabling openai-v2. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 17c6d96 commit 3b66c84

4 files changed

Lines changed: 220 additions & 12 deletions

File tree

instrumentation-genai/opentelemetry-instrumentation-langchain/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to this repository are documented in this file.
44

5+
## Unreleased
6+
7+
### Added
8+
- **`OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI` env var** — Controls whether
9+
the LangChain instrumentor injects the `suppress_language_model_instrumentation`
10+
context key to prevent `opentelemetry-instrumentation-openai-v2` from creating
11+
its own spans. Defaults to `true` (preserves existing behavior). Set to `false`
12+
in zero-code deployments together with
13+
`OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=openai`, or to allow both instrumentors
14+
to emit spans side by side in manual setups.
15+
516
## Version 0.1.14
617

718
### Bump version for release

instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/__init__.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import inspect
55
import logging
6+
import os
67
from typing import Collection
78

89
from opentelemetry import context as context_api
@@ -20,10 +21,24 @@
2021
)
2122
from wrapt import wrap_function_wrapper
2223

24+
from .environment_variables import OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI
2325
from .semconv_ai import SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY
2426

2527
logger = logging.getLogger(__name__)
2628

29+
30+
def _should_suppress_openai() -> bool:
31+
"""Return True when the suppression key should be set in the OTel context.
32+
33+
Reads OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI (default ``true``).
34+
Set to ``false`` together with OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=openai
35+
in zero-code deployments, or when you want both instrumentors to emit
36+
spans side by side in manual setups.
37+
"""
38+
raw = os.environ.get(OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI, "true")
39+
return raw.strip().lower() not in ("false", "0", "no", "off")
40+
41+
2742
_instruments = ("langchain-core > 0.1.0",)
2843

2944
# Embedding patches configuration
@@ -425,9 +440,12 @@ def __call__(
425440
# Sync (non-generator) path: attach, execute, detach.
426441
token = None
427442
try:
428-
token = context_api.attach(
429-
context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True)
430-
)
443+
if _should_suppress_openai():
444+
token = context_api.attach(
445+
context_api.set_value(
446+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True
447+
)
448+
)
431449
return wrapped(*args, **kwargs)
432450
finally:
433451
if token is not None:
@@ -438,9 +456,12 @@ async def _wrap_coroutine(coro):
438456
"""Await a coroutine with LLM instrumentation suppressed."""
439457
token = None
440458
try:
441-
token = context_api.attach(
442-
context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True)
443-
)
459+
if _should_suppress_openai():
460+
token = context_api.attach(
461+
context_api.set_value(
462+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True
463+
)
464+
)
444465
return await coro
445466
finally:
446467
if token is not None:
@@ -451,9 +472,12 @@ async def _wrap_async_generator(agen):
451472
"""Iterate an async generator with LLM instrumentation suppressed."""
452473
token = None
453474
try:
454-
token = context_api.attach(
455-
context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True)
456-
)
475+
if _should_suppress_openai():
476+
token = context_api.attach(
477+
context_api.set_value(
478+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True
479+
)
480+
)
457481
async for item in agen:
458482
yield item
459483
finally:
@@ -465,9 +489,12 @@ def _wrap_sync_generator(gen):
465489
"""Iterate a sync generator with LLM instrumentation suppressed."""
466490
token = None
467491
try:
468-
token = context_api.attach(
469-
context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True)
470-
)
492+
if _should_suppress_openai():
493+
token = context_api.attach(
494+
context_api.set_value(
495+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True
496+
)
497+
)
471498
yield from gen
472499
finally:
473500
if token is not None:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI = (
16+
"OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI"
17+
)
18+
"""
19+
.. envvar:: OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI
20+
21+
Controls whether the LangChain instrumentor suppresses OpenAI spans when both
22+
``opentelemetry-instrumentation-langchain`` and
23+
``opentelemetry-instrumentation-openai-v2`` are active at the same time.
24+
25+
When ``true`` (default), the LangChain instrumentor injects a suppression key
26+
into the OTel context before every OpenAI API call so that the openai-v2
27+
instrumentor skips creating its own spans. This prevents duplicate LLM-level
28+
telemetry when both packages are manually instrumented together.
29+
30+
When ``false``, the suppression key is **not** set. Use this together with
31+
``OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=openai`` (zero-code / auto-instrumentation)
32+
so that the openai-v2 instrumentor is not loaded at all, or when you explicitly
33+
want both instrumentors to emit their own spans side by side.
34+
35+
Default: ``true``
36+
37+
Examples::
38+
39+
# Zero-code: disable the suppression key; openai-v2 is already excluded
40+
# via OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=openai
41+
export OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI=false
42+
43+
# Manual instrumentation: preserve the default suppression behavior
44+
# (suppress is true, no setting needed)
45+
46+
# Manual instrumentation: allow both to emit spans simultaneously
47+
export OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI=false
48+
"""
49+
50+
__all__ = ["OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI"]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Unit tests for OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI env var.
17+
18+
Verifies that _should_suppress_openai() correctly parses all truthy/falsey
19+
values and that the LangChain instrumentor skips setting the
20+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY when the env var is false.
21+
"""
22+
23+
import pytest
24+
25+
from opentelemetry.instrumentation.langchain import _should_suppress_openai
26+
from opentelemetry.instrumentation.langchain.environment_variables import (
27+
OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI,
28+
)
29+
30+
31+
# ---------------------------------------------------------------------------
32+
# _should_suppress_openai() parsing
33+
# ---------------------------------------------------------------------------
34+
35+
36+
@pytest.mark.parametrize(
37+
"value",
38+
["true", "True", "TRUE", "1", "yes", "on", " true "],
39+
)
40+
def test_should_suppress_openai_truthy(monkeypatch, value):
41+
"""Truthy env var values return True (suppression is active)."""
42+
monkeypatch.setenv(OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI, value)
43+
assert _should_suppress_openai() is True
44+
45+
46+
@pytest.mark.parametrize(
47+
"value",
48+
["false", "False", "FALSE", "0", "no", "off", " false "],
49+
)
50+
def test_should_suppress_openai_falsey(monkeypatch, value):
51+
"""Falsey env var values return False (suppression is disabled)."""
52+
monkeypatch.setenv(OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI, value)
53+
assert _should_suppress_openai() is False
54+
55+
56+
def test_should_suppress_openai_default(monkeypatch):
57+
"""When the env var is unset the default is True (backward compat)."""
58+
monkeypatch.delenv(OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI, raising=False)
59+
assert _should_suppress_openai() is True
60+
61+
62+
# ---------------------------------------------------------------------------
63+
# Integration: suppression key is NOT set when env var is false
64+
# ---------------------------------------------------------------------------
65+
66+
67+
def test_suppress_key_not_set_when_env_var_false(monkeypatch):
68+
"""When OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI=false the
69+
suppression context key must not be set during an OpenAI wrapper call."""
70+
monkeypatch.setenv(OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI, "false")
71+
72+
from opentelemetry import context as context_api
73+
from opentelemetry.instrumentation.langchain import _OpenAITracingWrapper
74+
from opentelemetry.util.genai.attributes import (
75+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
76+
)
77+
78+
captured_value = {}
79+
80+
def fake_wrapped(*args, **kwargs):
81+
captured_value["suppress"] = context_api.get_value(
82+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY
83+
)
84+
return "ok"
85+
86+
wrapper = _OpenAITracingWrapper(callback_manager=None)
87+
result = wrapper(fake_wrapped, None, (), {})
88+
89+
assert result == "ok"
90+
assert captured_value.get("suppress") is None, (
91+
"Suppression key must not be set when env var is false"
92+
)
93+
94+
95+
def test_suppress_key_set_when_env_var_true(monkeypatch):
96+
"""When OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI=true (default) the
97+
suppression context key IS set during the wrapper call."""
98+
monkeypatch.setenv(OTEL_INSTRUMENTATION_LANGCHAIN_SUPPRESS_OPENAI, "true")
99+
100+
from opentelemetry import context as context_api
101+
from opentelemetry.instrumentation.langchain import _OpenAITracingWrapper
102+
from opentelemetry.util.genai.attributes import (
103+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
104+
)
105+
106+
captured_value = {}
107+
108+
def fake_wrapped(*args, **kwargs):
109+
captured_value["suppress"] = context_api.get_value(
110+
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY
111+
)
112+
return "ok"
113+
114+
wrapper = _OpenAITracingWrapper(callback_manager=None)
115+
result = wrapper(fake_wrapped, None, (), {})
116+
117+
assert result == "ok"
118+
assert captured_value.get("suppress") is True, (
119+
"Suppression key must be set when env var is true"
120+
)

0 commit comments

Comments
 (0)