Skip to content

Commit 3c5f101

Browse files
authored
Merge branch 'main' into fix/gh-609
2 parents 8dd618d + dc1fedd commit 3c5f101

9 files changed

Lines changed: 411 additions & 147 deletions

File tree

.github/actions/spelling/allow.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Llm
6363
lstrips
6464
mikeas
6565
mockurl
66+
mysqladmin
6667
notif
6768
oauthoidc
6869
oidc
@@ -71,6 +72,7 @@ otherurl
7172
postgres
7273
POSTGRES
7374
postgresql
75+
proot
7476
protoc
7577
pyi
7678
pypistats
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Ignore URLs
2+
https?://\S+

.github/workflows/linter.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,13 @@ jobs:
2727

2828
- name: Run Ruff Linter
2929
id: ruff-lint
30-
uses: astral-sh/ruff-action@v3
30+
run: uv run ruff check --output-format=github
3131
continue-on-error: true
3232

3333
- name: Run Ruff Formatter
3434
id: ruff-format
35-
uses: astral-sh/ruff-action@v3
35+
run: uv run ruff format --check
3636
continue-on-error: true
37-
with:
38-
args: "format --check"
3937

4038
- name: Run MyPy Type Checker
4139
id: mypy

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",

scripts/docker-compose.test.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
services:
2+
postgres:
3+
image: postgres:15-alpine
4+
environment:
5+
POSTGRES_USER: a2a
6+
POSTGRES_PASSWORD: a2a_password
7+
POSTGRES_DB: a2a_test
8+
ports:
9+
- "5432:5432"
10+
healthcheck:
11+
test: ["CMD-SHELL", "pg_isready"]
12+
interval: 10s
13+
timeout: 5s
14+
retries: 5
15+
16+
mysql:
17+
image: mysql:8.0
18+
environment:
19+
MYSQL_ROOT_PASSWORD: root
20+
MYSQL_DATABASE: a2a_test
21+
MYSQL_USER: a2a
22+
MYSQL_PASSWORD: a2a_password
23+
ports:
24+
- "3306:3306"
25+
healthcheck:
26+
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -proot"]
27+
interval: 10s
28+
timeout: 5s
29+
retries: 5

scripts/run_integration_tests.sh

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Get the directory of this script
5+
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
6+
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
7+
8+
# Docker compose file path
9+
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.test.yml"
10+
11+
# Initialize variables
12+
DEBUG_MODE=false
13+
STOP_MODE=false
14+
SERVICES=()
15+
PYTEST_ARGS=()
16+
17+
# Parse arguments
18+
while [[ $# -gt 0 ]]; do
19+
case $1 in
20+
--debug)
21+
DEBUG_MODE=true
22+
shift
23+
;;
24+
--stop)
25+
STOP_MODE=true
26+
shift
27+
;;
28+
--postgres)
29+
SERVICES+=("postgres")
30+
shift
31+
;;
32+
--mysql)
33+
SERVICES+=("mysql")
34+
shift
35+
;;
36+
*)
37+
# Preserve other arguments for pytest
38+
PYTEST_ARGS+=("$1")
39+
shift
40+
;;
41+
esac
42+
done
43+
44+
# Handle --stop
45+
if [[ "$STOP_MODE" == "true" ]]; then
46+
echo "Stopping test databases..."
47+
docker compose -f "$COMPOSE_FILE" down
48+
exit 0
49+
fi
50+
51+
# Default to running both databases if none specified
52+
if [[ ${#SERVICES[@]} -eq 0 ]]; then
53+
SERVICES=("postgres" "mysql")
54+
fi
55+
56+
# Cleanup function to stop docker containers
57+
cleanup() {
58+
echo "Stopping test databases..."
59+
docker compose -f "$COMPOSE_FILE" down
60+
}
61+
62+
# Start the databases
63+
echo "Starting/Verifying databases: ${SERVICES[*]}..."
64+
docker compose -f "$COMPOSE_FILE" up -d --wait "${SERVICES[@]}"
65+
66+
# Set up environment variables based on active services
67+
# Only export DSNs for started services so tests skip missing ones
68+
for service in "${SERVICES[@]}"; do
69+
if [[ "$service" == "postgres" ]]; then
70+
export POSTGRES_TEST_DSN="postgresql+asyncpg://a2a:a2a_password@localhost:5432/a2a_test"
71+
elif [[ "$service" == "mysql" ]]; then
72+
export MYSQL_TEST_DSN="mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test"
73+
fi
74+
done
75+
76+
# Handle --debug mode
77+
if [[ "$DEBUG_MODE" == "true" ]]; then
78+
echo "---------------------------------------------------"
79+
echo "Debug mode enabled. Databases are running."
80+
echo "You can connect to them using the following DSNs."
81+
echo ""
82+
echo "Run the following commands to set up your environment:"
83+
echo ""
84+
[[ -n "$POSTGRES_TEST_DSN" ]] && echo "export POSTGRES_TEST_DSN=\"$POSTGRES_TEST_DSN\""
85+
[[ -n "$MYSQL_TEST_DSN" ]] && echo "export MYSQL_TEST_DSN=\"$MYSQL_TEST_DSN\""
86+
echo ""
87+
echo "---------------------------------------------------"
88+
echo "Run ./scripts/run_integration_tests.sh --stop to shut databases down."
89+
exit 0
90+
fi
91+
92+
# Register cleanup trap for normal test run
93+
trap cleanup EXIT
94+
95+
# Run the tests
96+
echo "Running integration tests..."
97+
cd "$PROJECT_ROOT"
98+
99+
uv run --extra all pytest -v \
100+
tests/server/tasks/test_database_task_store.py \
101+
tests/server/tasks/test_database_push_notification_config_store.py \
102+
"${PYTEST_ARGS[@]}"

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

0 commit comments

Comments
 (0)