Skip to content

Commit a28513e

Browse files
authored
feat(sdk): add otel=True instrumentation flag for Python SDK (#117)
Adds opt-in OpenTelemetry gRPC instrumentation to ConfigClient and AsyncConfigClient. When otel=True, an OTel interceptor is wired outermost on the channel so get/set/watch RPCs appear in application traces. - New _otel.py module with lazy imports (no dep cost when otel=False) - otel optional extra: pip install opendecree[otel] - Works for sync client, async client, and watchers (inherit channel) - OTel interceptor is outermost (wraps user interceptors + auth) - 100% test coverage; 10 new unit tests Co-Authored-By: Claude <noreply@anthropic.com> Closes #23
1 parent a146d9f commit a28513e

6 files changed

Lines changed: 273 additions & 3 deletions

File tree

sdk/docs/configuration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ ConfigClient(
2121
# Behavior
2222
timeout: float = 10.0, # default RPC timeout in seconds
2323
retry: RetryConfig | None = RetryConfig(), # retry config (None to disable)
24+
25+
# Observability
26+
otel: bool = False, # wire OTel gRPC interceptor (requires opendecree[otel])
2427
)
2528
```
2629

@@ -146,6 +149,33 @@ client.set(
146149
Use `idempotency_key` only when the write is genuinely safe to apply more than once — for example,
147150
setting a field to a known constant value.
148151

152+
## OpenTelemetry instrumentation
153+
154+
Pass `otel=True` to wire an OpenTelemetry gRPC interceptor. Get, set, and watch RPCs will appear as spans in your application traces.
155+
156+
```python
157+
from opendecree import ConfigClient
158+
159+
client = ConfigClient("localhost:9090", otel=True)
160+
```
161+
162+
Requires the optional extra:
163+
164+
```
165+
pip install 'opendecree[otel]'
166+
```
167+
168+
The OTel interceptor is outermost — it wraps both user-supplied interceptors and the SDK's internal auth interceptor, so every outbound RPC is traced end-to-end. The same flag works on `AsyncConfigClient`:
169+
170+
```python
171+
from opendecree import AsyncConfigClient
172+
173+
async with AsyncConfigClient("localhost:9090", otel=True) as client:
174+
val = await client.get("tenant-id", "payments.fee")
175+
```
176+
177+
The watcher inherits the client's already-instrumented channel — no additional configuration needed.
178+
149179
## Timeouts
150180

151181
The `timeout` parameter sets the default per-RPC deadline in seconds:

sdk/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ dependencies = [
2828
]
2929

3030
[project.optional-dependencies]
31+
otel = [
32+
"opentelemetry-instrumentation-grpc>=0.50b0",
33+
]
3134
dev = [
3235
"pytest>=9.0.3",
3336
"pytest-cov>=7.1.0",
@@ -80,6 +83,10 @@ ignore_missing_imports = true
8083
module = "google.rpc.*"
8184
ignore_missing_imports = true
8285

86+
[[tool.mypy.overrides]]
87+
module = "opentelemetry.*"
88+
ignore_missing_imports = true
89+
8390
[tool.pytest.ini_options]
8491
testpaths = ["tests"]
8592
asyncio_mode = "auto"

sdk/src/opendecree/_otel.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Optional OpenTelemetry gRPC instrumentation.
2+
3+
Lazily imports opentelemetry-instrumentation-grpc so the core SDK does not
4+
require OTel as a dependency. Install the optional extra:
5+
6+
pip install 'opendecree[otel]'
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
_HINT = "Install: pip install 'opendecree[otel]'"
14+
15+
16+
def make_sync_interceptor() -> Any:
17+
"""Return an OpenTelemetryClientInterceptor for use with grpc.intercept_channel."""
18+
try:
19+
from opentelemetry.instrumentation.grpc import OpenTelemetryClientInterceptor
20+
except ImportError as exc:
21+
raise ImportError(
22+
f"opentelemetry-instrumentation-grpc is required when otel=True. {_HINT}"
23+
) from exc
24+
return OpenTelemetryClientInterceptor()
25+
26+
27+
def make_aio_interceptors() -> list[Any]:
28+
"""Return OTel interceptors for a grpc.aio channel.
29+
30+
Uses the internal _aio_client module — there is no public per-channel API
31+
for async OTel instrumentation in opentelemetry-instrumentation-grpc.
32+
"""
33+
try:
34+
from opentelemetry.instrumentation.grpc._aio_client import (
35+
OpenTelemetryAioClientInterceptor,
36+
)
37+
except ImportError as exc:
38+
raise ImportError(
39+
f"opentelemetry-instrumentation-grpc is required when otel=True. {_HINT}"
40+
) from exc
41+
return [OpenTelemetryAioClientInterceptor()]

sdk/src/opendecree/async_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def __init__(
5555
retry: RetryConfig | None = None,
5656
check_version: bool = False,
5757
interceptors: list[Any] | None = None,
58+
otel: bool = False,
5859
) -> None:
5960
"""Create a new AsyncConfigClient.
6061
@@ -80,6 +81,10 @@ def __init__(
8081
interceptors: Optional list of :class:`grpc.aio.ClientInterceptor`
8182
instances to inject (e.g., for logging, tracing, or metrics).
8283
Passed directly to the ``grpc.aio`` channel.
84+
otel: When True, wire an OpenTelemetry gRPC client interceptor so
85+
get/set/watch RPCs appear in your application traces. Requires
86+
``pip install 'opendecree[otel]'``. The OTel interceptor is
87+
outermost, wrapping all other interceptors.
8388
"""
8489
self._timeout = timeout
8590
self._retry = retry if retry is not None else RetryConfig()
@@ -105,12 +110,21 @@ def __init__(
105110
self._auth_metadata = _build_metadata(
106111
subject=subject, role=role, tenant_id=tenant_id, token=metadata_token
107112
)
113+
114+
# OTel interceptors are outermost; user interceptors follow.
115+
all_interceptors: list[Any] = []
116+
if otel:
117+
from opendecree._otel import make_aio_interceptors
118+
119+
all_interceptors.extend(make_aio_interceptors())
120+
all_interceptors.extend(interceptors or [])
121+
108122
self._channel = create_aio_channel(
109123
target,
110124
insecure=insecure,
111125
credentials=credentials,
112126
token=channel_token,
113-
interceptors=interceptors,
127+
interceptors=all_interceptors or None,
114128
)
115129

116130
cs_pb2, cs_grpc = ensure_stubs()

sdk/src/opendecree/client.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(
5858
retry: RetryConfig | None = None,
5959
check_version: bool = False,
6060
interceptors: list[Any] | None = None,
61+
otel: bool = False,
6162
) -> None:
6263
"""Create a new ConfigClient.
6364
@@ -86,6 +87,10 @@ def __init__(
8687
(e.g., for logging, tracing, or metrics). User-supplied
8788
interceptors are applied outermost (before the SDK's internal
8889
auth interceptor).
90+
otel: When True, wire an OpenTelemetry gRPC client interceptor so
91+
get/set/watch RPCs appear in your application traces. Requires
92+
``pip install 'opendecree[otel]'``. The OTel interceptor is
93+
outermost, wrapping all other interceptors.
8994
"""
9095
self._timeout = timeout
9196
self._retry = retry if retry is not None else RetryConfig()
@@ -109,8 +114,13 @@ def __init__(
109114
metadata = _build_metadata(
110115
subject=subject, role=role, tenant_id=tenant_id, token=metadata_token
111116
)
112-
# User interceptors are outermost; auth interceptor runs inside them.
113-
all_interceptors: list[Any] = list(interceptors) if interceptors else []
117+
# OTel → user interceptors → auth interceptor (outermost first).
118+
all_interceptors: list[Any] = []
119+
if otel:
120+
from opendecree._otel import make_sync_interceptor
121+
122+
all_interceptors.append(make_sync_interceptor())
123+
all_interceptors.extend(interceptors or [])
114124
if metadata:
115125
all_interceptors.append(AuthInterceptor(metadata))
116126

sdk/tests/test_otel.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Tests for OTel opt-in instrumentation flag."""
2+
3+
import sys
4+
from types import ModuleType
5+
from unittest.mock import MagicMock, patch
6+
7+
import pytest
8+
9+
10+
def _fake_otel_modules() -> dict[str, ModuleType]:
11+
"""Build a minimal fake opentelemetry-instrumentation-grpc module tree."""
12+
fake_interceptor = MagicMock(name="OpenTelemetryClientInterceptor")
13+
fake_aio_interceptor = MagicMock(name="OpenTelemetryAioClientInterceptor")
14+
15+
grpc_mod = ModuleType("opentelemetry.instrumentation.grpc")
16+
grpc_mod.OpenTelemetryClientInterceptor = fake_interceptor # type: ignore[attr-defined]
17+
18+
aio_client_mod = ModuleType("opentelemetry.instrumentation.grpc._aio_client")
19+
aio_client_mod.OpenTelemetryAioClientInterceptor = fake_aio_interceptor # type: ignore[attr-defined]
20+
21+
otel_mod = ModuleType("opentelemetry")
22+
instr_mod = ModuleType("opentelemetry.instrumentation")
23+
24+
return {
25+
"opentelemetry": otel_mod,
26+
"opentelemetry.instrumentation": instr_mod,
27+
"opentelemetry.instrumentation.grpc": grpc_mod,
28+
"opentelemetry.instrumentation.grpc._aio_client": aio_client_mod,
29+
}
30+
31+
32+
class TestMakeSyncInterceptor:
33+
def test_returns_interceptor_when_package_installed(self):
34+
from opendecree._otel import make_sync_interceptor
35+
36+
with patch.dict(sys.modules, _fake_otel_modules()):
37+
result = make_sync_interceptor()
38+
assert result is not None
39+
40+
def test_raises_import_error_when_not_installed(self):
41+
from opendecree._otel import make_sync_interceptor
42+
43+
blocked = {k: None for k in _fake_otel_modules()} # type: ignore[assignment]
44+
with patch.dict(sys.modules, blocked):
45+
with pytest.raises(ImportError, match="opendecree\\[otel\\]"):
46+
make_sync_interceptor()
47+
48+
49+
class TestMakeAioInterceptors:
50+
def test_returns_list_when_package_installed(self):
51+
from opendecree._otel import make_aio_interceptors
52+
53+
with patch.dict(sys.modules, _fake_otel_modules()):
54+
result = make_aio_interceptors()
55+
assert isinstance(result, list)
56+
assert len(result) == 1
57+
58+
def test_raises_import_error_when_not_installed(self):
59+
from opendecree._otel import make_aio_interceptors
60+
61+
blocked = {k: None for k in _fake_otel_modules()} # type: ignore[assignment]
62+
with patch.dict(sys.modules, blocked):
63+
with pytest.raises(ImportError, match="opendecree\\[otel\\]"):
64+
make_aio_interceptors()
65+
66+
67+
class TestSyncClientOtel:
68+
def test_otel_false_does_not_call_make_interceptor(self):
69+
with patch("opendecree.client.create_channel") as mock_ch:
70+
mock_ch.return_value = MagicMock()
71+
with patch("opendecree.client.grpc.intercept_channel"):
72+
with patch("opendecree._otel.make_sync_interceptor") as mock_otel:
73+
import opendecree
74+
75+
opendecree.ConfigClient("localhost:9090")
76+
mock_otel.assert_not_called()
77+
78+
def test_otel_true_prepends_interceptor(self):
79+
fake_otel_interceptor = MagicMock(name="otel_interceptor")
80+
81+
with patch("opendecree.client.create_channel") as mock_ch:
82+
mock_ch.return_value = MagicMock()
83+
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
84+
mock_intercept.return_value = MagicMock()
85+
with patch.dict(sys.modules, _fake_otel_modules()):
86+
# Make make_sync_interceptor return our fake
87+
with patch(
88+
"opendecree._otel.make_sync_interceptor",
89+
return_value=fake_otel_interceptor,
90+
):
91+
import opendecree
92+
93+
opendecree.ConfigClient("localhost:9090", otel=True)
94+
95+
interceptors_used = mock_intercept.call_args[0][1:]
96+
assert interceptors_used[0] is fake_otel_interceptor
97+
98+
def test_otel_true_otel_before_user_interceptors(self):
99+
fake_otel_interceptor = MagicMock(name="otel_interceptor")
100+
user_interceptor = MagicMock(name="user_interceptor")
101+
102+
with patch("opendecree.client.create_channel") as mock_ch:
103+
mock_ch.return_value = MagicMock()
104+
with patch("opendecree.client.grpc.intercept_channel") as mock_intercept:
105+
mock_intercept.return_value = MagicMock()
106+
with patch(
107+
"opendecree._otel.make_sync_interceptor",
108+
return_value=fake_otel_interceptor,
109+
):
110+
import opendecree
111+
112+
opendecree.ConfigClient(
113+
"localhost:9090", otel=True, interceptors=[user_interceptor]
114+
)
115+
116+
interceptors_used = mock_intercept.call_args[0][1:]
117+
otel_idx = list(interceptors_used).index(fake_otel_interceptor)
118+
user_idx = list(interceptors_used).index(user_interceptor)
119+
assert otel_idx < user_idx
120+
121+
122+
class TestAsyncClientOtel:
123+
def test_otel_false_does_not_call_make_interceptors(self):
124+
with patch("opendecree.async_client.create_aio_channel") as mock_ch:
125+
mock_ch.return_value = MagicMock()
126+
with patch("opendecree._otel.make_aio_interceptors") as mock_otel:
127+
import opendecree
128+
129+
opendecree.AsyncConfigClient("localhost:9090")
130+
mock_otel.assert_not_called()
131+
132+
def test_otel_true_passes_interceptors_to_channel(self):
133+
fake_otel_interceptor = MagicMock(name="aio_otel_interceptor")
134+
135+
with patch("opendecree.async_client.create_aio_channel") as mock_ch:
136+
mock_ch.return_value = MagicMock()
137+
with patch(
138+
"opendecree._otel.make_aio_interceptors",
139+
return_value=[fake_otel_interceptor],
140+
):
141+
import opendecree
142+
143+
opendecree.AsyncConfigClient("localhost:9090", otel=True)
144+
145+
interceptors_passed = mock_ch.call_args.kwargs.get("interceptors")
146+
assert interceptors_passed is not None
147+
assert fake_otel_interceptor in interceptors_passed
148+
149+
def test_otel_true_otel_before_user_interceptors(self):
150+
fake_otel_interceptor = MagicMock(name="aio_otel_interceptor")
151+
user_interceptor = MagicMock(name="user_interceptor")
152+
153+
with patch("opendecree.async_client.create_aio_channel") as mock_ch:
154+
mock_ch.return_value = MagicMock()
155+
with patch(
156+
"opendecree._otel.make_aio_interceptors",
157+
return_value=[fake_otel_interceptor],
158+
):
159+
import opendecree
160+
161+
opendecree.AsyncConfigClient(
162+
"localhost:9090", otel=True, interceptors=[user_interceptor]
163+
)
164+
165+
interceptors_passed = mock_ch.call_args.kwargs.get("interceptors")
166+
otel_idx = interceptors_passed.index(fake_otel_interceptor)
167+
user_idx = interceptors_passed.index(user_interceptor)
168+
assert otel_idx < user_idx

0 commit comments

Comments
 (0)