Skip to content

Commit 72216b9

Browse files
authored
feat: support disabling OTel instrumentation via env var (a2aproject#611)
# Description Support disabling OTEL instrumentation via the a2a-sdk using an environment variable `OTEL_A2A_SDK_INSTRUMENTATION_ENABLED` Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - Important Prefixes for [release-please](https://github.com/googleapis/release-please): - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. - `feat:` represents a new feature, and correlates to a SemVer minor. - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Fixes a2aproject#604 🦕 Release-As: 0.3.23
1 parent 12fd75c commit 72216b9

4 files changed

Lines changed: 133 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ exclude = ["tests/"]
7272
testpaths = ["tests"]
7373
python_files = "test_*.py"
7474
python_functions = "test_*"
75-
addopts = "-ra --strict-markers"
75+
addopts = "-ra --strict-markers --dist loadgroup"
7676
markers = [
7777
"asyncio: mark a test as a coroutine that should be run by pytest-asyncio",
78+
"xdist_group: mark a test to run in a specific sequential group for isolation",
7879
]
7980

8081
[tool.pytest-asyncio]
@@ -93,6 +94,7 @@ dev = [
9394
"pytest-asyncio>=0.26.0",
9495
"pytest-cov>=6.1.1",
9596
"pytest-mock>=3.14.0",
97+
"pytest-xdist>=3.6.1",
9698
"respx>=0.20.2",
9799
"ruff>=0.12.8",
98100
"uv-dynamic-versioning>=0.8.2",

src/a2a/utils/telemetry.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
- Automatic recording of exceptions and setting of span status.
1919
- Selective method tracing in classes using include/exclude lists.
2020
21+
Configuration:
22+
- Environment Variable Control: OpenTelemetry instrumentation can be
23+
disabled using the `OTEL_INSTRUMENTATION_A2A_SDK_ENABLED` environment
24+
variable.
25+
26+
- Default: `true` (tracing enabled when OpenTelemetry is installed)
27+
- To disable: Set `OTEL_INSTRUMENTATION_A2A_SDK_ENABLED=false`
28+
- Case insensitive: 'true', 'True', 'TRUE' all enable tracing
29+
- Any other value disables tracing and logs a debug message
30+
2131
Usage:
2232
For a single function:
2333
```python
@@ -57,10 +67,13 @@ def internal_method(self):
5767
import functools
5868
import inspect
5969
import logging
70+
import os
6071

6172
from collections.abc import Callable
6273
from typing import TYPE_CHECKING, Any
6374

75+
from typing_extensions import Self
76+
6477

6578
if TYPE_CHECKING:
6679
from opentelemetry.trace import SpanKind as SpanKindType
@@ -74,19 +87,41 @@ def internal_method(self):
7487
from opentelemetry.trace import SpanKind as _SpanKind
7588
from opentelemetry.trace import StatusCode
7689

90+
otel_installed = True
91+
7792
except ImportError:
7893
logger.debug(
7994
'OpenTelemetry not found. Tracing will be disabled. '
8095
'Install with: \'pip install "a2a-sdk[telemetry]"\''
8196
)
97+
otel_installed = False
98+
99+
ENABLED_ENV_VAR = 'OTEL_INSTRUMENTATION_A2A_SDK_ENABLED'
100+
INSTRUMENTING_MODULE_NAME = 'a2a-python-sdk'
101+
INSTRUMENTING_MODULE_VERSION = '1.0.0'
102+
103+
# Check if tracing is enabled via environment variable
104+
env_value = os.getenv(ENABLED_ENV_VAR, 'true')
105+
otel_enabled = env_value.lower() == 'true'
106+
107+
# Log when tracing is explicitly disabled via environment variable
108+
if otel_installed and not otel_enabled:
109+
logger.debug(
110+
'A2A OTEL instrumentation disabled via environment variable '
111+
'%s=%r. Tracing will be disabled.',
112+
ENABLED_ENV_VAR,
113+
env_value,
114+
)
115+
116+
if not otel_installed or not otel_enabled:
82117

83118
class _NoOp:
84119
"""A no-op object that absorbs all tracing calls when OpenTelemetry is not installed."""
85120

86121
def __call__(self, *args: Any, **kwargs: Any) -> Any:
87122
return self
88123

89-
def __enter__(self) -> '_NoOp':
124+
def __enter__(self) -> Self:
90125
return self
91126

92127
def __exit__(self, *args: object, **kwargs: Any) -> None:
@@ -99,12 +134,9 @@ def __getattr__(self, name: str) -> Any:
99134
_SpanKind = _NoOp() # type: ignore
100135
StatusCode = _NoOp() # type: ignore
101136

102-
SpanKind = _SpanKind
137+
SpanKind = _SpanKind # type: ignore
103138
__all__ = ['SpanKind']
104139

105-
INSTRUMENTING_MODULE_NAME = 'a2a-python-sdk'
106-
INSTRUMENTING_MODULE_VERSION = '1.0.0'
107-
108140

109141
def trace_function( # noqa: PLR0915
110142
func: Callable | None = None,

tests/utils/test_telemetry.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import asyncio
2+
import importlib
3+
import sys
24

3-
from collections.abc import Generator
5+
from collections.abc import Callable, Generator
46
from typing import Any, NoReturn
57
from unittest import mock
68

@@ -30,6 +32,32 @@ def patch_trace_get_tracer(
3032
yield
3133

3234

35+
@pytest.fixture
36+
def reload_telemetry_module(
37+
monkeypatch: pytest.MonkeyPatch,
38+
) -> Generator[Callable[[str | None], Any], None, None]:
39+
"""Fixture to handle telemetry module reloading with env var control."""
40+
41+
def _reload(env_value: str | None = None) -> Any:
42+
if env_value is None:
43+
monkeypatch.delenv(
44+
'OTEL_INSTRUMENTATION_A2A_SDK_ENABLED', raising=False
45+
)
46+
else:
47+
monkeypatch.setenv(
48+
'OTEL_INSTRUMENTATION_A2A_SDK_ENABLED', env_value
49+
)
50+
51+
sys.modules.pop('a2a.utils.telemetry', None)
52+
module = importlib.import_module('a2a.utils.telemetry')
53+
return module
54+
55+
yield _reload
56+
57+
# Cleanup to ensure other tests aren't affected by a "poisoned" sys.modules
58+
sys.modules.pop('a2a.utils.telemetry', None)
59+
60+
3361
def test_trace_function_sync_success(mock_span: mock.MagicMock) -> None:
3462
@trace_function
3563
def foo(x, y):
@@ -198,3 +226,43 @@ def foo(self) -> str:
198226
assert obj.foo() == 'foo'
199227
assert hasattr(obj.foo, '__wrapped__')
200228
assert hasattr(obj, 'x')
229+
230+
231+
@pytest.mark.xdist_group(name='telemetry_isolation')
232+
@pytest.mark.parametrize(
233+
'env_value,expected_tracing',
234+
[
235+
(None, True), # Default: env var not set, tracing enabled
236+
('true', True), # Explicitly enabled
237+
('True', True), # Case insensitive
238+
('false', False), # Disabled
239+
('', False), # Empty string = false
240+
],
241+
)
242+
def test_env_var_controls_instrumentation(
243+
reload_telemetry_module: Callable[[str | None], Any],
244+
env_value: str | None,
245+
expected_tracing: bool,
246+
) -> None:
247+
"""Test OTEL_INSTRUMENTATION_A2A_SDK_ENABLED controls span creation."""
248+
telemetry_module = reload_telemetry_module(env_value)
249+
250+
is_noop = type(telemetry_module.trace).__name__ == '_NoOp'
251+
252+
assert is_noop != expected_tracing
253+
254+
255+
@pytest.mark.xdist_group(name='telemetry_isolation')
256+
def test_env_var_disabled_logs_message(
257+
reload_telemetry_module: Callable[[str | None], Any],
258+
caplog: pytest.LogCaptureFixture,
259+
) -> None:
260+
"""Test that disabling via env var logs appropriate debug message."""
261+
with caplog.at_level('DEBUG', logger='a2a.utils.telemetry'):
262+
reload_telemetry_module('false')
263+
264+
assert (
265+
'A2A OTEL instrumentation disabled via environment variable'
266+
in caplog.text
267+
)
268+
assert 'OTEL_INSTRUMENTATION_A2A_SDK_ENABLED' in caplog.text

uv.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)