Skip to content

Commit 3fb684b

Browse files
authored
Add OTel auto-instrumentation (#163)
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
1 parent 3d4f317 commit 3fb684b

9 files changed

Lines changed: 388 additions & 35 deletions

File tree

connectrpc-otel/README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
# connectrpc-otel
22

3-
OpenTelemetry middleware for connect-python to generate server and client spans
4-
for ConnectRPC requests.
5-
6-
Auto-instrumentation is currently not supported.
3+
OpenTelemetry instrumentation for connect-python to generate server and client spans and metrics
4+
for ConnectRPC requests with support for auto-instrumentation.
75

86
## Example
97

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3-
__all__ = ["OpenTelemetryInterceptor"]
3+
__all__ = ["ConnectInstrumentor", "OpenTelemetryInterceptor"]
44

5+
from ._instrumentor import ConnectInstrumentor
56
from ._interceptor import OpenTelemetryInterceptor
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from functools import partial
5+
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
6+
7+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
8+
from opentelemetry.instrumentation.utils import unwrap
9+
from wrapt import register_post_import_hook, wrap_function_wrapper
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Callable, Collection
13+
from types import ModuleType
14+
15+
from opentelemetry.metrics import MeterProvider
16+
from opentelemetry.trace import TracerProvider
17+
18+
from connectrpc.interceptor import Interceptor, InterceptorSync
19+
20+
_instruments = ("connect-python>=0.9.0",)
21+
22+
P = ParamSpec("P")
23+
R = TypeVar("R")
24+
25+
26+
class ConnectInstrumentor(BaseInstrumentor):
27+
def instrumentation_dependencies(self) -> Collection[str]:
28+
return _instruments
29+
30+
def _instrument(self, **kwargs: object) -> None:
31+
self._meter_provider = cast(
32+
"MeterProvider | None", kwargs.get("meter_provider")
33+
)
34+
self._tracer_provider = cast(
35+
"TracerProvider | None", kwargs.get("tracer_provider")
36+
)
37+
38+
register_post_import_hook(self._patch_client, "connectrpc.client")
39+
register_post_import_hook(self._patch_server, "connectrpc.server")
40+
41+
def _uninstrument(self, **kwargs: object) -> None:
42+
# TODO: Remove sys.modules check after
43+
# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4321
44+
if "connectrpc.client" in sys.modules:
45+
unwrap("connectrpc.client.ConnectClient", "__init__")
46+
unwrap("connectrpc.client.ConnectClientSync", "__init__")
47+
if "connectrpc.server" in sys.modules:
48+
unwrap("connectrpc.server.ConnectASGIApplication", "__init__")
49+
unwrap("connectrpc.server.ConnectWSGIApplication", "__init__")
50+
51+
def _patch_client(self, module: ModuleType) -> None:
52+
wrap_function_wrapper(
53+
module, "ConnectClient.__init__", partial(self._init_wrapper, client=True)
54+
)
55+
wrap_function_wrapper(
56+
module,
57+
"ConnectClientSync.__init__",
58+
partial(self._init_wrapper, client=True),
59+
)
60+
61+
def _patch_server(self, module: ModuleType) -> None:
62+
wrap_function_wrapper(
63+
module,
64+
"ConnectASGIApplication.__init__",
65+
partial(self._init_wrapper, client=False),
66+
)
67+
wrap_function_wrapper(
68+
module,
69+
"ConnectWSGIApplication.__init__",
70+
partial(self._init_wrapper, client=False),
71+
)
72+
73+
def _init_wrapper(
74+
self,
75+
wrapped: Callable[P, R],
76+
_instance: object,
77+
args: tuple,
78+
kwargs: dict,
79+
*,
80+
client: bool,
81+
) -> R:
82+
# Instrumentation doesn't eager import the library it instruments
83+
from connectrpc_otel import OpenTelemetryInterceptor # noqa: PLC0415
84+
85+
interceptors: list[Interceptor | InterceptorSync] | None = kwargs.get(
86+
"interceptors"
87+
)
88+
interceptors = [] if interceptors is None else [*interceptors]
89+
if any(isinstance(i, OpenTelemetryInterceptor) for i in interceptors):
90+
return wrapped(*args, **kwargs)
91+
92+
kwargs["interceptors"] = interceptors
93+
# OpenTelemetry interceptor should be first so i.e. logging interceptors
94+
# have trace IDs.
95+
interceptors.insert(
96+
0,
97+
OpenTelemetryInterceptor(
98+
tracer_provider=self._tracer_provider,
99+
meter_provider=self._meter_provider,
100+
client=client,
101+
),
102+
)
103+
104+
return wrapped(*args, **kwargs)

connectrpc-otel/pyproject.toml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,23 @@ classifiers = [
3535
"Programming Language :: Python :: 3.14",
3636
"Topic :: System :: Networking",
3737
]
38-
dependencies = ["connect-python>=0.8.0", "opentelemetry-api>=1.39.1"]
38+
dependencies = [
39+
"opentelemetry-api>=1.39.1",
40+
"opentelemetry-instrumentation==0.60b1",
41+
]
3942

4043
[dependency-groups]
4144
dev = [
45+
# auto-instrumentation shouldn't include a dependency on the library being instrumented
46+
# so it can be packaged in auto-instrumentation distros for general use.
47+
#
48+
# This library also supports manually initializing the interceptor.
49+
# For users that manually instrument by initializing the interceptor, it is easier to
50+
# have a real dependency to avoid any possible version conflicts. But for Python,
51+
# the ecosystem vastly favors auto-instrumentation, and it is easier to add than remove
52+
# a transitive dependency, so we go ahead and leave it out.
53+
"connect-python>=0.8.0",
54+
4255
"opentelemetry-sdk==1.39.1",
4356
"opentelemetry-instrumentation-asgi==0.60b1",
4457
"opentelemetry-instrumentation-wsgi==0.60b1",
@@ -47,6 +60,9 @@ dev = [
4760
"pytest",
4861
]
4962

63+
[project.entry-points.opentelemetry_instrumentor]
64+
connectrpc = "connectrpc_otel:ConnectInstrumentor"
65+
5066
[project.urls]
5167
Homepage = "https://github.com/connectrpc/connect-python"
5268
Repository = "https://github.com/connectrpc/connect-python"

connectrpc-otel/test/__init__.py

Whitespace-only changes.

connectrpc-otel/test/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from opentelemetry.sdk.metrics import MeterProvider
5+
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
6+
from opentelemetry.sdk.trace import TracerProvider
7+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
8+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
9+
10+
11+
@pytest.fixture
12+
def span_exporter() -> InMemorySpanExporter:
13+
return InMemorySpanExporter()
14+
15+
16+
@pytest.fixture
17+
def tracer_provider(span_exporter: InMemorySpanExporter) -> TracerProvider:
18+
tp = TracerProvider()
19+
tp.add_span_processor(SimpleSpanProcessor(span_exporter))
20+
return tp
21+
22+
23+
@pytest.fixture
24+
def metric_reader() -> InMemoryMetricReader:
25+
return InMemoryMetricReader()
26+
27+
28+
@pytest.fixture
29+
def meter_provider(metric_reader: InMemoryMetricReader) -> MeterProvider:
30+
return MeterProvider(metric_readers=[metric_reader])

0 commit comments

Comments
 (0)