Skip to content

Commit 8a1bfc3

Browse files
authored
Add OpenTelemetry middleware (#150)
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
1 parent a7e846a commit 8a1bfc3

19 files changed

Lines changed: 1111 additions & 94 deletions

.github/workflows/ci.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767
go-version-file: protoc-gen-connect-python/go.mod
6868
cache-dependency-path: "**/go.mod"
6969

70-
- run: uv sync
70+
- run: uv sync --all-packages
7171

7272
- name: run lints
7373
if: startsWith(matrix.os, 'ubuntu-')
@@ -85,6 +85,10 @@ jobs:
8585
run: uv run pytest ${{ matrix.coverage == 'cov' && '--cov=connectrpc --cov-report=xml' || '' }}
8686
working-directory: conformance
8787

88+
- name: run OTel tests
89+
run: uv run pytest ${{ matrix.coverage == 'cov' && '--cov=connectrpc --cov-report=xml' || '' }}
90+
working-directory: connectrpc-otel
91+
8892
- name: run Go tests
8993
run: go test ./...
9094
working-directory: protoc-gen-connect-python

connect-python.code-workspace

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"name": "conformance",
99
"path": "./conformance",
1010
},
11+
{
12+
"name": "connectrpc-otel",
13+
"path": "./connectrpc-otel",
14+
},
1115
{
1216
"name": "example",
1317
"path": "./example",

connectrpc-otel/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# connectrpc-otel
2+
3+
OpenTelemetry middleware for connect-python to generate server and client spans
4+
for ConnectRPC requests.
5+
6+
Auto-instrumentation is currently not supported.
7+
8+
## Example
9+
10+
```python
11+
12+
from connectrpc_otel import OpenTelemetryInterceptor
13+
14+
from eliza_connect import ElizaServiceWSGIApplication, ElizaServiceClientSync
15+
16+
from ._service import MyElizaService
17+
18+
app = ElizaServiceWSGIApplication(MyElizaService(), interceptors=[OpenTelemetryInterceptor()])
19+
20+
def make_request():
21+
client = ElizaServiceClientSync("http://localhost:8080", interceptors=[OpenTelemetryInterceptor(client=True)])
22+
resp = client.Say(SayRequest(sentence="Hello!"))
23+
print(resp)
24+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
__all__ = ["OpenTelemetryInterceptor"]
4+
5+
from ._interceptor import OpenTelemetryInterceptor
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
from contextlib import AbstractContextManager, contextmanager
4+
from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast
5+
6+
from opentelemetry.propagate import get_global_textmap
7+
from opentelemetry.propagators.textmap import Setter, TextMapPropagator, default_setter
8+
from opentelemetry.trace import (
9+
Span,
10+
SpanKind,
11+
TracerProvider,
12+
get_current_span,
13+
get_tracer_provider,
14+
)
15+
16+
from connectrpc.errors import ConnectError
17+
18+
from ._semconv import (
19+
CLIENT_ADDRESS,
20+
CLIENT_PORT,
21+
ERROR_TYPE,
22+
RPC_METHOD,
23+
RPC_RESPONSE_STATUS_CODE,
24+
RPC_SYSTEM_NAME,
25+
SERVER_ADDRESS,
26+
SERVER_PORT,
27+
RpcSystemNameValues,
28+
)
29+
from ._version import __version__
30+
31+
if TYPE_CHECKING:
32+
from collections.abc import Iterator, MutableMapping
33+
34+
from opentelemetry.util.types import AttributeValue
35+
36+
from connectrpc.request import RequestContext
37+
38+
REQ = TypeVar("REQ")
39+
RES = TypeVar("RES")
40+
41+
Token: TypeAlias = tuple[AbstractContextManager, Span]
42+
43+
# Workaround bad typing
44+
_DEFAULT_TEXTMAP_SETTER = cast("Setter[MutableMapping[str, str]]", default_setter)
45+
46+
47+
class OpenTelemetryInterceptor:
48+
"""Interceptor to generate telemetry for RPC server and client requests."""
49+
50+
def __init__(
51+
self,
52+
*,
53+
propagator: TextMapPropagator | None = None,
54+
tracer_provider: TracerProvider | None = None,
55+
client: bool = False,
56+
) -> None:
57+
"""Creates a new OpenTelemetry interceptor.
58+
59+
Args:
60+
propagator: The OpenTelemetry TextMapPropagator to use. If not
61+
provided, the global default will be used.
62+
tracer_provider: The OpenTelemetry TracerProvider to use. If not
63+
provided, the global default will be used.
64+
client: Whether this interceptor is for a client or server.
65+
"""
66+
self._client = client
67+
tracer_provider = tracer_provider or get_tracer_provider()
68+
self._tracer = tracer_provider.get_tracer("connectrpc-otel", __version__)
69+
self._propagator = propagator or get_global_textmap()
70+
71+
async def on_start(self, ctx: RequestContext) -> Token:
72+
return self.on_start_sync(ctx)
73+
74+
def on_start_sync(self, ctx: RequestContext) -> Token:
75+
cm = self._start_span(ctx)
76+
span = cm.__enter__()
77+
return cm, span
78+
79+
async def on_end(
80+
self, token: Token, ctx: RequestContext, error: Exception | None
81+
) -> None:
82+
self.on_end_sync(token, ctx, error)
83+
84+
def on_end_sync(
85+
self, token: Token, ctx: RequestContext, error: Exception | None
86+
) -> None:
87+
cm, span = token
88+
self._finish_span(span, error)
89+
if error:
90+
cm.__exit__(type(error), error, error.__traceback__)
91+
else:
92+
cm.__exit__(None, None, None)
93+
94+
@contextmanager
95+
def _start_span(self, ctx: RequestContext) -> Iterator[Span]:
96+
parent_otel_ctx = None
97+
if self._client:
98+
span_kind = SpanKind.CLIENT
99+
carrier = ctx.request_headers()
100+
self._propagator.inject(carrier, setter=_DEFAULT_TEXTMAP_SETTER)
101+
else:
102+
span_kind = SpanKind.SERVER
103+
parent_span = get_current_span()
104+
if not parent_span.get_span_context().is_valid:
105+
carrier = ctx.request_headers()
106+
parent_otel_ctx = self._propagator.extract(carrier)
107+
108+
rpc_method = f"{ctx.method().service_name}/{ctx.method().name}"
109+
110+
attrs: MutableMapping[str, AttributeValue] = {
111+
RPC_SYSTEM_NAME: RpcSystemNameValues.CONNECTRPC.value,
112+
RPC_METHOD: rpc_method,
113+
}
114+
if sa := ctx.server_address():
115+
addr, port = sa.rsplit(":", 1)
116+
attrs[SERVER_ADDRESS] = addr
117+
attrs[SERVER_PORT] = int(port)
118+
if ca := ctx.client_address():
119+
addr, port = ca.rsplit(":", 1)
120+
attrs[CLIENT_ADDRESS] = addr
121+
attrs[CLIENT_PORT] = int(port)
122+
123+
with self._tracer.start_as_current_span(
124+
rpc_method, kind=span_kind, attributes=attrs, context=parent_otel_ctx
125+
) as span:
126+
yield span
127+
128+
def _finish_span(self, span: Span, error: Exception | None) -> None:
129+
if error:
130+
if isinstance(error, ConnectError):
131+
span.set_attribute(RPC_RESPONSE_STATUS_CODE, error.code.value)
132+
else:
133+
span.set_attribute(RPC_RESPONSE_STATUS_CODE, "unknown")
134+
span.set_attribute(ERROR_TYPE, type(error).__qualname__)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Vendored in OpenTelemetry semantic conventions for connect-python to avoid
2+
# unstable imports. We don't copy docstrings since for us they are implementation
3+
# details and should be obvious enough.
4+
5+
# https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-semantic-conventions/src/opentelemetry/semconv/_incubating/attributes/rpc_attributes.py
6+
from __future__ import annotations
7+
8+
from enum import Enum
9+
from typing import Final
10+
11+
CLIENT_ADDRESS: Final = "client.address"
12+
CLIENT_PORT: Final = "client.port"
13+
ERROR_TYPE: Final = "error.type"
14+
RPC_METHOD: Final = "rpc.method"
15+
RPC_RESPONSE_STATUS_CODE: Final = "rpc.response.status_code"
16+
RPC_SYSTEM_NAME: Final = "rpc.system.name"
17+
SERVER_ADDRESS: Final = "server.address"
18+
SERVER_PORT: Final = "server.port"
19+
20+
21+
class RpcSystemNameValues(Enum):
22+
CONNECTRPC = "connectrpc"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
from importlib.metadata import version
4+
5+
__version__ = version("connectrpc-otel")

connectrpc-otel/pyproject.toml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[project]
2+
name = "connectrpc-otel"
3+
version = "0.1.0"
4+
description = "OpenTelemetry instrumentation for connectrpc"
5+
maintainers = [
6+
{ name = "Anuraag Agrawal", email = "anuraaga@gmail.com" },
7+
{ name = "Spencer Nelson", email = "spencer@firetiger.com" },
8+
{ name = "Stefan VanBuren", email = "svanburen@buf.build" },
9+
{ name = "Yasushi Itoh", email = "i2y.may.roku@gmail.com" },
10+
]
11+
requires-python = ">= 3.10"
12+
readme = "README.md"
13+
license = "Apache-2.0"
14+
keywords = [
15+
"opentelemetry",
16+
"otel",
17+
"connectrpc",
18+
"connect-python",
19+
"middleware",
20+
"tracing",
21+
"observability",
22+
]
23+
classifiers = [
24+
"Development Status :: 4 - Beta",
25+
"Environment :: Console",
26+
"Intended Audience :: Developers",
27+
"License :: OSI Approved :: Apache Software License",
28+
"Operating System :: OS Independent",
29+
"Programming Language :: Python :: 3",
30+
"Programming Language :: Python :: 3 :: Only",
31+
"Programming Language :: Python :: 3.10",
32+
"Programming Language :: Python :: 3.11",
33+
"Programming Language :: Python :: 3.12",
34+
"Programming Language :: Python :: 3.13",
35+
"Programming Language :: Python :: 3.14",
36+
"Topic :: System :: Networking",
37+
]
38+
dependencies = ["connect-python>=0.8.0", "opentelemetry-api>=1.39.1"]
39+
40+
[dependency-groups]
41+
dev = [
42+
"opentelemetry-sdk==1.39.1",
43+
"opentelemetry-instrumentation-asgi==0.60b1",
44+
"opentelemetry-instrumentation-wsgi==0.60b1",
45+
46+
"connect-python-example",
47+
"pytest",
48+
]
49+
50+
[project.urls]
51+
Homepage = "https://github.com/connectrpc/connect-python"
52+
Repository = "https://github.com/connectrpc/connect-python"
53+
Issues = "https://github.com/connectrpc/connect-python/issues"
54+
55+
[build-system]
56+
requires = ["uv_build>=0.10.0,<0.11.0"]
57+
build-backend = "uv_build"
58+
59+
[tool.uv.build-backend]
60+
module-name = "connectrpc_otel"
61+
module-root = ""

0 commit comments

Comments
 (0)