Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#3624](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3624))
- `opentelemetry-instrumentation-dbapi`: fix crash retrieving libpq version when enabling commenter with psycopg
([#3796](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3796))
- `opentelemetry-instrumentation-grpc` User should be able to cancel grpc stream
([#2093](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2093))

### Added

- `opentelemetry-instrumentation`: botocore: Add support for AWS Secrets Manager semantic convention attribute
([#3765](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3765))
- Add `rstcheck` to pre-commit to stop introducing invalid RST
([#3777](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3777))
- `opentelemetry-exporter-credential-provider-gcp`: create this package which provides support for supplying your machine's Application Default
- `opentelemetry-exporter-credential-provider-gcp`: create this package which provides support for supplying your machine's Application Default
Credentials (https://cloud.google.com/docs/authentication/application-default-credentials) to the OTLP Exporters created automatically by OpenTelemetry Python's auto instrumentation. These credentials authorize OTLP traces to be sent to `telemetry.googleapis.com`. [#3766](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3766).

## Version 1.37.0/0.58b0 (2025-09-11)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@

import logging
from collections import OrderedDict
from functools import partial
from typing import Callable, MutableMapping

import grpc

from opentelemetry import trace
from opentelemetry.instrumentation.grpc import grpcext
from opentelemetry.instrumentation.grpc._utilities import RpcInfo
Expand Down Expand Up @@ -77,12 +77,11 @@ def _safe_invoke(function: Callable, *args):
"Error when invoking function '%s'", function_name, exc_info=ex
)


class OpenTelemetryClientInterceptor(
grpcext.UnaryClientInterceptor, grpcext.StreamClientInterceptor
):
def __init__(
self, tracer, filter_=None, request_hook=None, response_hook=None
self, tracer, filter_=None, request_hook=None, response_hook=None
):
self._tracer = tracer
self._filter = filter_
Expand Down Expand Up @@ -136,10 +135,10 @@ def _intercept(self, request, metadata, client_info, invoker):
else:
mutable_metadata = OrderedDict(metadata)
with self._start_span(
client_info.full_method,
end_on_exit=False,
record_exception=False,
set_status_on_exception=False,
client_info.full_method,
end_on_exit=False,
record_exception=False,
set_status_on_exception=False,
) as span:
result = None
try:
Expand Down Expand Up @@ -193,14 +192,17 @@ def intercept_unary(self, request, metadata, client_info, invoker):
# the span across the generated responses and detect any errors, we wrap
# the result in a new generator that yields the response values.
def _intercept_server_stream(
self, request_or_iterator, metadata, client_info, invoker
self, request_or_iterator, metadata, client_info, invoker
):
if not metadata:
mutable_metadata = OrderedDict()
else:
mutable_metadata = OrderedDict(metadata)

with self._start_span(client_info.full_method) as span:
with self._start_span(
client_info.full_method,
end_on_exit=False
) as span:
inject(mutable_metadata, setter=_carrier_setter)
metadata = tuple(mutable_metadata.items())
rpc_info = RpcInfo(
Expand All @@ -212,15 +214,29 @@ def _intercept_server_stream(
if client_info.is_client_stream:
rpc_info.request = request_or_iterator

try:
yield from invoker(request_or_iterator, metadata)
except grpc.RpcError as err:
span.set_status(Status(StatusCode.ERROR))
span.set_attribute(RPC_GRPC_STATUS_CODE, err.code().value[0])
raise err
stream = invoker(request_or_iterator, metadata)

def done_callback(future, span_):
try:
future.result()
except grpc.FutureCancelledError:
span_.set_status(Status(StatusCode.OK))
span_.set_attribute(
RPC_GRPC_STATUS_CODE, grpc.StatusCode.CANCELLED.value[0]
)
except grpc.RpcError as err:
span_.set_status(Status(StatusCode.ERROR))
span_.set_attribute(
RPC_GRPC_STATUS_CODE, err.code().value[0]
)
finally:
span_.end()

stream.add_done_callback(partial(done_callback, span_=span))
return stream

def intercept_stream(
self, request_or_iterator, metadata, client_info, invoker
self, request_or_iterator, metadata, client_info, invoker
):
if not is_instrumentation_enabled():
return invoker(request_or_iterator, metadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,19 @@ def request_messages():
)


def server_streaming_method(stub, error=False):
def server_streaming_method(stub, error=False, serialize=True):
request = Request(
client_id=CLIENT_ID, request_data="error" if error else "data"
)
response_iterator = stub.ServerStreamingMethod(
request, metadata=(("key", "value"),)
)
list(response_iterator)
if serialize:
list(response_iterator)
return response_iterator


def bidirectional_streaming_method(stub, error=False):
def bidirectional_streaming_method(stub, error=False, serialize=True):
def request_messages():
for _ in range(5):
request = Request(
Expand All @@ -67,4 +69,6 @@ def request_messages():
request_messages(), metadata=(("key", "value"),)
)

list(response_iterator)
if serialize:
list(response_iterator)
return response_iterator
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# pylint:disable=cyclic-import
import logging

import threading
import time
from unittest import mock

import grpc
Expand Down Expand Up @@ -61,32 +64,32 @@ def __init__(self):
pass

def intercept_unary_unary(
self, continuation, client_call_details, request
self, continuation, client_call_details, request
):
return self._intercept_call(continuation, client_call_details, request)

def intercept_unary_stream(
self, continuation, client_call_details, request
self, continuation, client_call_details, request
):
return self._intercept_call(continuation, client_call_details, request)

def intercept_stream_unary(
self, continuation, client_call_details, request_iterator
self, continuation, client_call_details, request_iterator
):
return self._intercept_call(
continuation, client_call_details, request_iterator
)

def intercept_stream_stream(
self, continuation, client_call_details, request_iterator
self, continuation, client_call_details, request_iterator
):
return self._intercept_call(
continuation, client_call_details, request_iterator
)

@staticmethod
def _intercept_call(
continuation, client_call_details, request_or_iterator
continuation, client_call_details, request_or_iterator
):
return continuation(client_call_details, request_or_iterator)

Expand Down Expand Up @@ -171,6 +174,40 @@ def test_unary_stream(self):
},
)

def test_unary_stream_can_be_cancel(self):
done = threading.Event()
responses = server_streaming_method(self._stub, serialize=False)
responses.add_done_callback(lambda: done.set())
for i, _ in enumerate(responses):
if i == 1:
responses.cancel()
break
self.assertEqual(responses.code(), grpc.StatusCode.CANCELLED)
done.wait(5)
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]

self.assertEqual(span.name, "/GRPCTestServer/ServerStreamingMethod")
self.assertIs(span.kind, trace.SpanKind.CLIENT)

# Check version and name in span's instrumentation info
self.assertEqualSpanInstrumentationScope(
span, opentelemetry.instrumentation.grpc
)

self.assertSpanHasAttributes(
span,
{
RPC_METHOD: "ServerStreamingMethod",
RPC_SERVICE: "GRPCTestServer",
RPC_SYSTEM: "grpc",
RPC_GRPC_STATUS_CODE: grpc.StatusCode.CANCELLED.value[
0
],
},
)

def test_stream_unary(self):
client_streaming_method(self._stub)
spans = self.memory_exporter.get_finished_spans()
Expand Down Expand Up @@ -221,6 +258,41 @@ def test_stream_stream(self):
},
)

def test_stream_stream_can_be_cancel(self):
done = threading.Event()
responses = bidirectional_streaming_method(self._stub, serialize=False)
responses.add_done_callback(lambda: done.set())
for i, _ in enumerate(responses):
if i == 1:
responses.cancel()
break
self.assertEqual(responses.code(), grpc.StatusCode.CANCELLED)
done.wait(5)
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]

self.assertEqual(span.name, "/GRPCTestServer/BidirectionalStreamingMethod")
self.assertIs(span.kind, trace.SpanKind.CLIENT)

# Check version and name in span's instrumentation info
self.assertEqualSpanInstrumentationScope(
span, opentelemetry.instrumentation.grpc
)

self.assertSpanHasAttributes(
span,
{
RPC_METHOD: "BidirectionalStreamingMethod",
RPC_SERVICE: "GRPCTestServer",
RPC_SYSTEM: "grpc",
RPC_GRPC_STATUS_CODE: grpc.StatusCode.CANCELLED.value[
0
],
},
)


def test_error_simple(self):
with self.assertRaises(grpc.RpcError):
simple_method(self._stub, error=True)
Expand Down Expand Up @@ -296,7 +368,7 @@ def invoker(_request, _metadata):
self.assertEqual(span_end_mock.call_count, 1)

def test_client_interceptor_trace_context_propagation(
self,
self,
): # pylint: disable=no-self-use
"""ensure that client interceptor correctly inject trace context into all outgoing requests."""
previous_propagator = get_global_textmap()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ def setUp(self):
self.server.start()
# use a user defined interceptor along with the opentelemetry client interceptor
interceptors = [Interceptor()]
self.channel = grpc.insecure_channel("localhost:25565")
self.channel = grpc.insecure_channel("localhost:25565",options=[
(grpc.experimental.ChannelOptions.SingleThreadedUnaryStream, 1)
])
self.channel = grpc.intercept_channel(self.channel, *interceptors)
self._stub = test_server_pb2_grpc.GRPCTestServerStub(self.channel)

Expand Down