-
Notifications
You must be signed in to change notification settings - Fork 6
Add OpenTelemetry middleware #150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
60822c0
90c09ba
912e7ad
b11dcfd
8fc9f42
c455314
2cc8fac
02527a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # connectrpc-otel | ||
|
|
||
| OpenTelemetry middleware for connect-python to generate server and client spans | ||
| for ConnectRPC requests. | ||
|
|
||
| Auto-instrumentation is currently not supported. | ||
|
|
||
| ## Example | ||
|
|
||
| ```python | ||
|
|
||
| from connectrpc_otel import OpenTelemetryInterceptor | ||
|
|
||
| from eliza_connect import ElizaServiceWSGIApplication, ElizaServiceClientSync | ||
|
|
||
| from ._service import MyElizaService | ||
|
|
||
| app = ElizaServiceWSGIApplication(MyElizaService(), interceptors=[OpenTelemetryInterceptor()]) | ||
|
|
||
| def make_request(): | ||
| client = ElizaServiceClientSync("http://localhost:8080", interceptors=[OpenTelemetryInterceptor(client=True)]) | ||
| resp = client.Say(SayRequest(sentence="Hello!")) | ||
| print(resp) | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from __future__ import annotations | ||
|
|
||
| __all__ = ["OpenTelemetryInterceptor"] | ||
|
|
||
| from ._interceptor import OpenTelemetryInterceptor |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from contextlib import AbstractContextManager, contextmanager | ||
| from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast | ||
|
|
||
| from opentelemetry.propagate import get_global_textmap | ||
| from opentelemetry.propagators.textmap import Setter, TextMapPropagator, default_setter | ||
| from opentelemetry.trace import ( | ||
| Span, | ||
| SpanKind, | ||
| TracerProvider, | ||
| get_current_span, | ||
| get_tracer_provider, | ||
| ) | ||
|
|
||
| from connectrpc.errors import ConnectError | ||
|
|
||
| from ._semconv import ( | ||
| CLIENT_ADDRESS, | ||
| CLIENT_PORT, | ||
| ERROR_TYPE, | ||
| RPC_METHOD, | ||
| RPC_RESPONSE_STATUS_CODE, | ||
| RPC_SYSTEM_NAME, | ||
| SERVER_ADDRESS, | ||
| SERVER_PORT, | ||
| RpcSystemNameValues, | ||
| ) | ||
| from ._version import __version__ | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Iterator, MutableMapping | ||
|
|
||
| from opentelemetry.util.types import AttributeValue | ||
|
|
||
| from connectrpc.request import RequestContext | ||
|
|
||
| REQ = TypeVar("REQ") | ||
| RES = TypeVar("RES") | ||
|
|
||
| Token: TypeAlias = tuple[AbstractContextManager, Span] | ||
|
|
||
| # Workaround bad typing | ||
| _DEFAULT_TEXTMAP_SETTER = cast("Setter[MutableMapping[str, str]]", default_setter) | ||
|
|
||
|
|
||
| class OpenTelemetryInterceptor: | ||
| """Interceptor to generate telemetry for RPC server and client requests.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| propagator: TextMapPropagator | None = None, | ||
| tracer_provider: TracerProvider | None = None, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would we also support stuff like a MeterProvider and the various knobs from otelconnect-go? (there's a lot of Options and I'm honestly not clear which ones are something we'd intend to support here, versus may be vestigial.)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, will add it with metrics later. Will also consider the options later - some like trust remote, attribute filters, are less common in Python than Go in my experience. |
||
| client: bool = False, | ||
| ) -> None: | ||
| """Creates a new OpenTelemetry interceptor. | ||
|
|
||
| Args: | ||
| propagator: The OpenTelemetry TextMapPropagator to use. If not | ||
| provided, the global default will be used. | ||
| tracer_provider: The OpenTelemetry TracerProvider to use. If not | ||
| provided, the global default will be used. | ||
| client: Whether this interceptor is for a client or server. | ||
| """ | ||
| self._client = client | ||
| tracer_provider = tracer_provider or get_tracer_provider() | ||
| self._tracer = tracer_provider.get_tracer("connectrpc-otel", __version__) | ||
| self._propagator = propagator or get_global_textmap() | ||
|
|
||
| async def on_start(self, ctx: RequestContext) -> Token: | ||
| return self.on_start_sync(ctx) | ||
|
|
||
| def on_start_sync(self, ctx: RequestContext) -> Token: | ||
| cm = self._start_span(ctx) | ||
| span = cm.__enter__() | ||
| return cm, span | ||
|
|
||
| async def on_end( | ||
| self, token: Token, ctx: RequestContext, error: Exception | None | ||
| ) -> None: | ||
| self.on_end_sync(token, ctx, error) | ||
|
|
||
| def on_end_sync( | ||
| self, token: Token, ctx: RequestContext, error: Exception | None | ||
| ) -> None: | ||
| cm, span = token | ||
| self._finish_span(span, error) | ||
| if error: | ||
| cm.__exit__(type(error), error, error.__traceback__) | ||
| else: | ||
| cm.__exit__(None, None, None) | ||
|
|
||
| @contextmanager | ||
| def _start_span(self, ctx: RequestContext) -> Iterator[Span]: | ||
| parent_otel_ctx = None | ||
| if self._client: | ||
| span_kind = SpanKind.CLIENT | ||
| carrier = ctx.request_headers() | ||
| self._propagator.inject(carrier, setter=_DEFAULT_TEXTMAP_SETTER) | ||
| else: | ||
| span_kind = SpanKind.SERVER | ||
| parent_span = get_current_span() | ||
| if not parent_span.get_span_context().is_valid: | ||
| carrier = ctx.request_headers() | ||
| parent_otel_ctx = self._propagator.extract(carrier) | ||
|
|
||
| rpc_method = f"{ctx.method().service_name}/{ctx.method().name}" | ||
|
|
||
| attrs: MutableMapping[str, AttributeValue] = { | ||
| RPC_SYSTEM_NAME: RpcSystemNameValues.CONNECTRPC.value, | ||
| RPC_METHOD: rpc_method, | ||
| } | ||
| if sa := ctx.server_address(): | ||
| addr, port = sa.rsplit(":", 1) | ||
| attrs[SERVER_ADDRESS] = addr | ||
| attrs[SERVER_PORT] = int(port) | ||
| if ca := ctx.client_address(): | ||
| addr, port = ca.rsplit(":", 1) | ||
| attrs[CLIENT_ADDRESS] = addr | ||
| attrs[CLIENT_PORT] = int(port) | ||
|
|
||
| with self._tracer.start_as_current_span( | ||
| rpc_method, kind=span_kind, attributes=attrs, context=parent_otel_ctx | ||
| ) as span: | ||
| yield span | ||
|
|
||
| def _finish_span(self, span: Span, error: Exception | None) -> None: | ||
| if error: | ||
| if isinstance(error, ConnectError): | ||
| span.set_attribute(RPC_RESPONSE_STATUS_CODE, error.code.value) | ||
| else: | ||
| span.set_attribute(RPC_RESPONSE_STATUS_CODE, "unknown") | ||
| span.set_attribute(ERROR_TYPE, type(error).__qualname__) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # Vendored in OpenTelemetry semantic conventions for connect-python to avoid | ||
| # unstable imports. We don't copy docstrings since for us they are implementation | ||
| # details and should be obvious enough. | ||
|
Comment on lines
+1
to
+3
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does otel ever stabilize this stuff (/ have a plan to stabilize this stuff)? Just wondering if we need to leave this interceptor at
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After many many years yeah :) HTTP and DB are finally stable, RPC is at RC. So I think it's safe to use even in a 1.x if we beat them to it We're definitely not going to use the old attributes for this new library to keep ourselves sane
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BTW in terms of import, I wish python had two artifacts, one with stable and one with incubating, but they only have one with I filed open-telemetry/opentelemetry-python#4952 to see if this may change |
||
|
|
||
| # https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-semantic-conventions/src/opentelemetry/semconv/_incubating/attributes/rpc_attributes.py | ||
| from __future__ import annotations | ||
|
|
||
| from enum import Enum | ||
| from typing import Final | ||
|
|
||
| CLIENT_ADDRESS: Final = "client.address" | ||
| CLIENT_PORT: Final = "client.port" | ||
| ERROR_TYPE: Final = "error.type" | ||
| RPC_METHOD: Final = "rpc.method" | ||
| RPC_RESPONSE_STATUS_CODE: Final = "rpc.response.status_code" | ||
| RPC_SYSTEM_NAME: Final = "rpc.system.name" | ||
| SERVER_ADDRESS: Final = "server.address" | ||
| SERVER_PORT: Final = "server.port" | ||
|
|
||
|
|
||
| class RpcSystemNameValues(Enum): | ||
| CONNECTRPC = "connectrpc" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since we support gRPC, would we plan on eventually supporting gRPC via this interceptor? or do that elsewhere?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup will add it to this interceptor later |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from importlib.metadata import version | ||
|
|
||
| __version__ = version("connectrpc-otel") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| [project] | ||
| name = "connectrpc-otel" | ||
| version = "0.1.0" | ||
| description = "OpenTelemetry instrumentation for connectrpc" | ||
| maintainers = [ | ||
| { name = "Anuraag Agrawal", email = "anuraaga@gmail.com" }, | ||
| { name = "Spencer Nelson", email = "spencer@firetiger.com" }, | ||
| { name = "Stefan VanBuren", email = "svanburen@buf.build" }, | ||
| { name = "Yasushi Itoh", email = "i2y.may.roku@gmail.com" }, | ||
| ] | ||
| requires-python = ">= 3.10" | ||
| readme = "README.md" | ||
| license = "Apache-2.0" | ||
| keywords = [ | ||
| "opentelemetry", | ||
| "otel", | ||
| "connectrpc", | ||
| "connect-python", | ||
| "middleware", | ||
| "tracing", | ||
| "observability", | ||
| ] | ||
| classifiers = [ | ||
| "Development Status :: 4 - Beta", | ||
| "Environment :: Console", | ||
| "Intended Audience :: Developers", | ||
| "License :: OSI Approved :: Apache Software License", | ||
| "Operating System :: OS Independent", | ||
| "Programming Language :: Python :: 3", | ||
| "Programming Language :: Python :: 3 :: Only", | ||
| "Programming Language :: Python :: 3.10", | ||
| "Programming Language :: Python :: 3.11", | ||
| "Programming Language :: Python :: 3.12", | ||
| "Programming Language :: Python :: 3.13", | ||
| "Programming Language :: Python :: 3.14", | ||
| "Topic :: System :: Networking", | ||
| ] | ||
| dependencies = ["connect-python>=0.8.0", "opentelemetry-api>=1.39.1"] | ||
|
|
||
| [dependency-groups] | ||
| dev = [ | ||
| "opentelemetry-sdk==1.39.1", | ||
| "opentelemetry-instrumentation-asgi==0.60b1", | ||
| "opentelemetry-instrumentation-wsgi==0.60b1", | ||
|
|
||
| "connect-python-example", | ||
| "pytest", | ||
| ] | ||
|
|
||
| [project.urls] | ||
| Homepage = "https://github.com/connectrpc/connect-python" | ||
| Repository = "https://github.com/connectrpc/connect-python" | ||
| Issues = "https://github.com/connectrpc/connect-python/issues" | ||
|
|
||
| [build-system] | ||
| requires = ["uv_build>=0.10.0,<0.11.0"] | ||
| build-backend = "uv_build" | ||
|
|
||
| [tool.uv.build-backend] | ||
| module-name = "connectrpc_otel" | ||
| module-root = "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any upstream issue we ought to follow for this? (I'm not super familiar with the python otel repos.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Opening up otel-python repos in an IDE, it's all red ;) I think chasing down typing issues there will be a rabbit hole