From 5d9cd4f665a3c476631fa132261e051f38c0541b Mon Sep 17 00:00:00 2001 From: Hossein <93824777+hossein-raeisi@users.noreply.github.com> Date: Thu, 30 Mar 2023 15:29:42 +0330 Subject: [PATCH] Add integerations for socket and grpc (#1911) - The gRPC integration instruments all incoming requests and outgoing unary-unary, unary-stream grpc requests using grpcio channels. Use this integration to start or continue transactions for incoming grpc requests, create spans for outgoing requests, and ensure traces are properly propagated to downstream services. - The Socket integration to create spans for dns resolves and connection creations. --------- Co-authored-by: Anton Pirker --- .flake8 | 4 + .github/workflows/test-integration-grpc.yml | 73 +++++++ mypy.ini | 2 + sentry_sdk/consts.py | 4 + sentry_sdk/integrations/grpc/__init__.py | 2 + sentry_sdk/integrations/grpc/client.py | 82 ++++++++ sentry_sdk/integrations/grpc/server.py | 64 ++++++ sentry_sdk/integrations/socket.py | 89 +++++++++ setup.py | 1 + tests/conftest.py | 11 +- tests/integrations/grpc/__init__.py | 3 + .../integrations/grpc/grpc_test_service.proto | 11 + .../grpc/grpc_test_service_pb2.py | 28 +++ .../grpc/grpc_test_service_pb2.pyi | 32 +++ .../grpc/grpc_test_service_pb2_grpc.py | 79 ++++++++ tests/integrations/grpc/test_grpc.py | 189 ++++++++++++++++++ tests/integrations/socket/__init__.py | 3 + tests/integrations/socket/test_socket.py | 51 +++++ tox.ini | 12 +- 19 files changed, 734 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test-integration-grpc.yml create mode 100644 sentry_sdk/integrations/grpc/__init__.py create mode 100644 sentry_sdk/integrations/grpc/client.py create mode 100644 sentry_sdk/integrations/grpc/server.py create mode 100644 sentry_sdk/integrations/socket.py create mode 100644 tests/integrations/grpc/__init__.py create mode 100644 tests/integrations/grpc/grpc_test_service.proto create mode 100644 tests/integrations/grpc/grpc_test_service_pb2.py create mode 100644 tests/integrations/grpc/grpc_test_service_pb2.pyi create mode 100644 tests/integrations/grpc/grpc_test_service_pb2_grpc.py create mode 100644 tests/integrations/grpc/test_grpc.py create mode 100644 tests/integrations/socket/__init__.py create mode 100644 tests/integrations/socket/test_socket.py diff --git a/.flake8 b/.flake8 index 37f5883f00..fb02f4fdef 100644 --- a/.flake8 +++ b/.flake8 @@ -15,3 +15,7 @@ extend-ignore = # is a worse version of and conflicts with B902 (first argument of a classmethod should be named cls) N804, extend-exclude=checkouts,lol* +exclude = + # gRCP generated files + grpc_test_service_pb2.py + grpc_test_service_pb2_grpc.py \ No newline at end of file diff --git a/.github/workflows/test-integration-grpc.yml b/.github/workflows/test-integration-grpc.yml new file mode 100644 index 0000000000..15cfcca552 --- /dev/null +++ b/.github/workflows/test-integration-grpc.yml @@ -0,0 +1,73 @@ +name: Test grpc + +on: + push: + branches: + - master + - release/** + + pull_request: + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless + +jobs: + test: + name: grpc, python ${{ matrix.python-version }}, ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + + strategy: + fail-fast: false + matrix: + python-version: ["3.7","3.8","3.9","3.10","3.11"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-20.04] + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup Test Env + run: | + pip install codecov "tox>=3,<4" + + - name: Test grpc + timeout-minutes: 45 + shell: bash + run: | + set -x # print commands that are executed + coverage erase + + ./scripts/runtox.sh "py${{ matrix.python-version }}-grpc" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch + coverage combine .coverage* + coverage xml -i + codecov --file coverage.xml + + check_required_tests: + name: All grpc tests passed or skipped + needs: test + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-20.04 + steps: + - name: Check for failures + if: contains(needs.test.result, 'failure') + run: | + echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1 diff --git a/mypy.ini b/mypy.ini index 0d12e43280..e25c2f1eac 100644 --- a/mypy.ini +++ b/mypy.ini @@ -67,3 +67,5 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-arq.*] ignore_missing_imports = True +[mypy-grpc.*] +ignore_missing_imports = True diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index fd3d7435c0..99f3ca4c1f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -59,6 +59,8 @@ class OP: FUNCTION = "function" FUNCTION_AWS = "function.aws" FUNCTION_GCP = "function.gcp" + GRPC_CLIENT = "grpc.client" + GRPC_SERVER = "grpc.server" HTTP_CLIENT = "http.client" HTTP_CLIENT_STREAM = "http.client.stream" HTTP_SERVER = "http.server" @@ -83,6 +85,8 @@ class OP: VIEW_RENDER = "view.render" VIEW_RESPONSE_RENDER = "view.response.render" WEBSOCKET_SERVER = "websocket.server" + SOCKET_CONNECTION = "socket.connection" + SOCKET_DNS = "socket.dns" # This type exists to trick mypy and PyCharm into thinking `init` and `Client` diff --git a/sentry_sdk/integrations/grpc/__init__.py b/sentry_sdk/integrations/grpc/__init__.py new file mode 100644 index 0000000000..59bfd502e5 --- /dev/null +++ b/sentry_sdk/integrations/grpc/__init__.py @@ -0,0 +1,2 @@ +from .server import ServerInterceptor # noqa: F401 +from .client import ClientInterceptor # noqa: F401 diff --git a/sentry_sdk/integrations/grpc/client.py b/sentry_sdk/integrations/grpc/client.py new file mode 100644 index 0000000000..1eb3621b0b --- /dev/null +++ b/sentry_sdk/integrations/grpc/client.py @@ -0,0 +1,82 @@ +from sentry_sdk import Hub +from sentry_sdk._types import MYPY +from sentry_sdk.consts import OP +from sentry_sdk.integrations import DidNotEnable + +if MYPY: + from typing import Any, Callable, Iterator, Iterable, Union + +try: + import grpc + from grpc import ClientCallDetails, Call + from grpc._interceptor import _UnaryOutcome + from grpc.aio._interceptor import UnaryStreamCall + from google.protobuf.message import Message # type: ignore +except ImportError: + raise DidNotEnable("grpcio is not installed") + + +class ClientInterceptor( + grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor # type: ignore +): + def intercept_unary_unary(self, continuation, client_call_details, request): + # type: (ClientInterceptor, Callable[[ClientCallDetails, Message], _UnaryOutcome], ClientCallDetails, Message) -> _UnaryOutcome + hub = Hub.current + method = client_call_details.method + + with hub.start_span( + op=OP.GRPC_CLIENT, description="unary unary call to %s" % method + ) as span: + span.set_data("type", "unary unary") + span.set_data("method", method) + + client_call_details = self._update_client_call_details_metadata_from_hub( + client_call_details, hub + ) + + response = continuation(client_call_details, request) + span.set_data("code", response.code().name) + + return response + + def intercept_unary_stream(self, continuation, client_call_details, request): + # type: (ClientInterceptor, Callable[[ClientCallDetails, Message], Union[Iterable[Any], UnaryStreamCall]], ClientCallDetails, Message) -> Union[Iterator[Message], Call] + hub = Hub.current + method = client_call_details.method + + with hub.start_span( + op=OP.GRPC_CLIENT, description="unary stream call to %s" % method + ) as span: + span.set_data("type", "unary stream") + span.set_data("method", method) + + client_call_details = self._update_client_call_details_metadata_from_hub( + client_call_details, hub + ) + + response = continuation( + client_call_details, request + ) # type: UnaryStreamCall + span.set_data("code", response.code().name) + + return response + + @staticmethod + def _update_client_call_details_metadata_from_hub(client_call_details, hub): + # type: (ClientCallDetails, Hub) -> ClientCallDetails + metadata = ( + list(client_call_details.metadata) if client_call_details.metadata else [] + ) + for key, value in hub.iter_trace_propagation_headers(): + metadata.append((key, value)) + + client_call_details = grpc._interceptor._ClientCallDetails( + method=client_call_details.method, + timeout=client_call_details.timeout, + metadata=metadata, + credentials=client_call_details.credentials, + wait_for_ready=client_call_details.wait_for_ready, + compression=client_call_details.compression, + ) + + return client_call_details diff --git a/sentry_sdk/integrations/grpc/server.py b/sentry_sdk/integrations/grpc/server.py new file mode 100644 index 0000000000..cdeea4a2fa --- /dev/null +++ b/sentry_sdk/integrations/grpc/server.py @@ -0,0 +1,64 @@ +from sentry_sdk import Hub +from sentry_sdk._types import MYPY +from sentry_sdk.consts import OP +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM + +if MYPY: + from typing import Callable, Optional + from google.protobuf.message import Message # type: ignore + +try: + import grpc + from grpc import ServicerContext, HandlerCallDetails, RpcMethodHandler +except ImportError: + raise DidNotEnable("grpcio is not installed") + + +class ServerInterceptor(grpc.ServerInterceptor): # type: ignore + def __init__(self, find_name=None): + # type: (ServerInterceptor, Optional[Callable[[ServicerContext], str]]) -> None + self._find_method_name = find_name or ServerInterceptor._find_name + + super(ServerInterceptor, self).__init__() + + def intercept_service(self, continuation, handler_call_details): + # type: (ServerInterceptor, Callable[[HandlerCallDetails], RpcMethodHandler], HandlerCallDetails) -> RpcMethodHandler + handler = continuation(handler_call_details) + if not handler or not handler.unary_unary: + return handler + + def behavior(request, context): + # type: (Message, ServicerContext) -> Message + hub = Hub(Hub.current) + + name = self._find_method_name(context) + + if name: + metadata = dict(context.invocation_metadata()) + + transaction = Transaction.continue_from_headers( + metadata, + op=OP.GRPC_SERVER, + name=name, + source=TRANSACTION_SOURCE_CUSTOM, + ) + + with hub.start_transaction(transaction=transaction): + try: + return handler.unary_unary(request, context) + except BaseException as e: + raise e + else: + return handler.unary_unary(request, context) + + return grpc.unary_unary_rpc_method_handler( + behavior, + request_deserializer=handler.request_deserializer, + response_serializer=handler.response_serializer, + ) + + @staticmethod + def _find_name(context): + # type: (ServicerContext) -> str + return context._rpc_event.call_details.method.decode() diff --git a/sentry_sdk/integrations/socket.py b/sentry_sdk/integrations/socket.py new file mode 100644 index 0000000000..ebb51354b1 --- /dev/null +++ b/sentry_sdk/integrations/socket.py @@ -0,0 +1,89 @@ +import socket +from sentry_sdk import Hub +from sentry_sdk._types import MYPY +from sentry_sdk.consts import OP +from sentry_sdk.integrations import Integration + +if MYPY: + from socket import AddressFamily, SocketKind + from typing import Tuple, Optional, Union, List + +__all__ = ["SocketIntegration"] + + +class SocketIntegration(Integration): + identifier = "socket" + + @staticmethod + def setup_once(): + # type: () -> None + """ + patches two of the most used functions of socket: create_connection and getaddrinfo(dns resolver) + """ + _patch_create_connection() + _patch_getaddrinfo() + + +def _get_span_description(host, port): + # type: (Union[bytes, str, None], Union[str, int, None]) -> str + + try: + host = host.decode() # type: ignore + except (UnicodeDecodeError, AttributeError): + pass + + description = "%s:%s" % (host, port) # type: ignore + + return description + + +def _patch_create_connection(): + # type: () -> None + real_create_connection = socket.create_connection + + def create_connection( + address, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore + source_address=None, + ): + # type: (Tuple[Optional[str], int], Optional[float], Optional[Tuple[Union[bytearray, bytes, str], int]])-> socket.socket + hub = Hub.current + if hub.get_integration(SocketIntegration) is None: + return real_create_connection( + address=address, timeout=timeout, source_address=source_address + ) + + with hub.start_span( + op=OP.SOCKET_CONNECTION, + description=_get_span_description(address[0], address[1]), + ) as span: + span.set_data("address", address) + span.set_data("timeout", timeout) + span.set_data("source_address", source_address) + + return real_create_connection( + address=address, timeout=timeout, source_address=source_address + ) + + socket.create_connection = create_connection + + +def _patch_getaddrinfo(): + # type: () -> None + real_getaddrinfo = socket.getaddrinfo + + def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + # type: (Union[bytes, str, None], Union[str, int, None], int, int, int, int) -> List[Tuple[AddressFamily, SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]]] + hub = Hub.current + if hub.get_integration(SocketIntegration) is None: + return real_getaddrinfo(host, port, family, type, proto, flags) + + with hub.start_span( + op=OP.SOCKET_DNS, description=_get_span_description(host, port) + ) as span: + span.set_data("host", host) + span.set_data("port", port) + + return real_getaddrinfo(host, port, family, type, proto, flags) + + socket.getaddrinfo = getaddrinfo diff --git a/setup.py b/setup.py index 21b316def2..266e34a993 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,7 @@ def get_file_text(file_name): "fastapi": ["fastapi>=0.79.0"], "pymongo": ["pymongo>=3.1"], "opentelemetry": ["opentelemetry-distro>=0.35b0"], + "grpcio": ["grpcio>=1.21.1"] }, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/conftest.py b/tests/conftest.py index a83ef85f25..618f60d282 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -311,20 +311,21 @@ def flush(timeout=None, callback=None): monkeypatch.setattr(test_client.transport, "capture_event", append) monkeypatch.setattr(test_client, "flush", flush) - return EventStreamReader(events_r) + return EventStreamReader(events_r, events_w) return inner class EventStreamReader(object): - def __init__(self, file): - self.file = file + def __init__(self, read_file, write_file): + self.read_file = read_file + self.write_file = write_file def read_event(self): - return json.loads(self.file.readline().decode("utf-8")) + return json.loads(self.read_file.readline().decode("utf-8")) def read_flush(self): - assert self.file.readline() == b"flush\n" + assert self.read_file.readline() == b"flush\n" # scope=session ensures that fixture is run earlier diff --git a/tests/integrations/grpc/__init__.py b/tests/integrations/grpc/__init__.py new file mode 100644 index 0000000000..88a0a201e4 --- /dev/null +++ b/tests/integrations/grpc/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("grpc") diff --git a/tests/integrations/grpc/grpc_test_service.proto b/tests/integrations/grpc/grpc_test_service.proto new file mode 100644 index 0000000000..43497c7129 --- /dev/null +++ b/tests/integrations/grpc/grpc_test_service.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package grpc_test_server; + +service gRPCTestService{ + rpc TestServe(gRPCTestMessage) returns (gRPCTestMessage); +} + +message gRPCTestMessage { + string text = 1; +} diff --git a/tests/integrations/grpc/grpc_test_service_pb2.py b/tests/integrations/grpc/grpc_test_service_pb2.py new file mode 100644 index 0000000000..c68f255b4a --- /dev/null +++ b/tests/integrations/grpc/grpc_test_service_pb2.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: grpc_test_service.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x17grpc_test_service.proto\x12\x10grpc_test_server"\x1f\n\x0fgRPCTestMessage\x12\x0c\n\x04text\x18\x01 \x01(\t2d\n\x0fgRPCTestService\x12Q\n\tTestServe\x12!.grpc_test_server.gRPCTestMessage\x1a!.grpc_test_server.gRPCTestMessageb\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "grpc_test_service_pb2", globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _GRPCTESTMESSAGE._serialized_start = 45 + _GRPCTESTMESSAGE._serialized_end = 76 + _GRPCTESTSERVICE._serialized_start = 78 + _GRPCTESTSERVICE._serialized_end = 178 +# @@protoc_insertion_point(module_scope) diff --git a/tests/integrations/grpc/grpc_test_service_pb2.pyi b/tests/integrations/grpc/grpc_test_service_pb2.pyi new file mode 100644 index 0000000000..02a0b7045b --- /dev/null +++ b/tests/integrations/grpc/grpc_test_service_pb2.pyi @@ -0,0 +1,32 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" +import builtins +import google.protobuf.descriptor +import google.protobuf.message +import sys + +if sys.version_info >= (3, 8): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +@typing_extensions.final +class gRPCTestMessage(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + TEXT_FIELD_NUMBER: builtins.int + text: builtins.str + def __init__( + self, + *, + text: builtins.str = ..., + ) -> None: ... + def ClearField( + self, field_name: typing_extensions.Literal["text", b"text"] + ) -> None: ... + +global___gRPCTestMessage = gRPCTestMessage diff --git a/tests/integrations/grpc/grpc_test_service_pb2_grpc.py b/tests/integrations/grpc/grpc_test_service_pb2_grpc.py new file mode 100644 index 0000000000..73b7d94c16 --- /dev/null +++ b/tests/integrations/grpc/grpc_test_service_pb2_grpc.py @@ -0,0 +1,79 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +import tests.integrations.grpc.grpc_test_service_pb2 as grpc__test__service__pb2 + + +class gRPCTestServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.TestServe = channel.unary_unary( + "/grpc_test_server.gRPCTestService/TestServe", + request_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString, + response_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString, + ) + + +class gRPCTestServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def TestServe(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + +def add_gRPCTestServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + "TestServe": grpc.unary_unary_rpc_method_handler( + servicer.TestServe, + request_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString, + response_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + "grpc_test_server.gRPCTestService", rpc_method_handlers + ) + server.add_generic_rpc_handlers((generic_handler,)) + + +# This class is part of an EXPERIMENTAL API. +class gRPCTestService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def TestServe( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/grpc_test_server.gRPCTestService/TestServe", + grpc__test__service__pb2.gRPCTestMessage.SerializeToString, + grpc__test__service__pb2.gRPCTestMessage.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) diff --git a/tests/integrations/grpc/test_grpc.py b/tests/integrations/grpc/test_grpc.py new file mode 100644 index 0000000000..92883e9256 --- /dev/null +++ b/tests/integrations/grpc/test_grpc.py @@ -0,0 +1,189 @@ +from __future__ import absolute_import + +import os + +from concurrent import futures + +import grpc +import pytest + +from sentry_sdk import Hub, start_transaction +from sentry_sdk.consts import OP +from sentry_sdk.integrations.grpc.client import ClientInterceptor +from sentry_sdk.integrations.grpc.server import ServerInterceptor +from tests.integrations.grpc.grpc_test_service_pb2 import gRPCTestMessage +from tests.integrations.grpc.grpc_test_service_pb2_grpc import ( + gRPCTestServiceServicer, + add_gRPCTestServiceServicer_to_server, + gRPCTestServiceStub, +) + +PORT = 50051 +PORT += os.getpid() % 100 # avoid port conflicts when running tests in parallel + + +@pytest.mark.forked +def test_grpc_server_starts_transaction(sentry_init, capture_events_forksafe): + sentry_init(traces_sample_rate=1.0) + events = capture_events_forksafe() + + server = _set_up() + + with grpc.insecure_channel(f"localhost:{PORT}") as channel: + stub = gRPCTestServiceStub(channel) + stub.TestServe(gRPCTestMessage(text="test")) + + _tear_down(server=server) + + events.write_file.close() + event = events.read_event() + span = event["spans"][0] + + assert event["type"] == "transaction" + assert event["transaction_info"] == { + "source": "custom", + } + assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER + assert span["op"] == "test" + + +@pytest.mark.forked +def test_grpc_server_continues_transaction(sentry_init, capture_events_forksafe): + sentry_init(traces_sample_rate=1.0) + events = capture_events_forksafe() + + server = _set_up() + + with grpc.insecure_channel(f"localhost:{PORT}") as channel: + stub = gRPCTestServiceStub(channel) + + with start_transaction() as transaction: + metadata = ( + ( + "baggage", + "sentry-trace_id={trace_id},sentry-environment=test," + "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( + trace_id=transaction.trace_id + ), + ), + ( + "sentry-trace", + "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=transaction.span_id, + sampled=1, + ), + ), + ) + stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) + + _tear_down(server=server) + + events.write_file.close() + event = events.read_event() + span = event["spans"][0] + + assert event["type"] == "transaction" + assert event["transaction_info"] == { + "source": "custom", + } + assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER + assert event["contexts"]["trace"]["trace_id"] == transaction.trace_id + assert span["op"] == "test" + + +@pytest.mark.forked +def test_grpc_client_starts_span(sentry_init, capture_events_forksafe): + sentry_init(traces_sample_rate=1.0) + events = capture_events_forksafe() + interceptors = [ClientInterceptor()] + + server = _set_up() + + with grpc.insecure_channel(f"localhost:{PORT}") as channel: + channel = grpc.intercept_channel(channel, *interceptors) + stub = gRPCTestServiceStub(channel) + + with start_transaction(): + stub.TestServe(gRPCTestMessage(text="test")) + + _tear_down(server=server) + + events.write_file.close() + events.read_event() + local_transaction = events.read_event() + span = local_transaction["spans"][0] + + assert len(local_transaction["spans"]) == 1 + assert span["op"] == OP.GRPC_CLIENT + assert ( + span["description"] + == "unary unary call to /grpc_test_server.gRPCTestService/TestServe" + ) + assert span["data"] == { + "type": "unary unary", + "method": "/grpc_test_server.gRPCTestService/TestServe", + "code": "OK", + } + + +@pytest.mark.forked +def test_grpc_client_and_servers_interceptors_integration( + sentry_init, capture_events_forksafe +): + sentry_init(traces_sample_rate=1.0) + events = capture_events_forksafe() + interceptors = [ClientInterceptor()] + + server = _set_up() + + with grpc.insecure_channel(f"localhost:{PORT}") as channel: + channel = grpc.intercept_channel(channel, *interceptors) + stub = gRPCTestServiceStub(channel) + + with start_transaction(): + stub.TestServe(gRPCTestMessage(text="test")) + + _tear_down(server=server) + + events.write_file.close() + server_transaction = events.read_event() + local_transaction = events.read_event() + + assert ( + server_transaction["contexts"]["trace"]["trace_id"] + == local_transaction["contexts"]["trace"]["trace_id"] + ) + + +def _set_up(): + server = grpc.server( + futures.ThreadPoolExecutor(max_workers=2), + interceptors=[ServerInterceptor(find_name=_find_name)], + ) + + add_gRPCTestServiceServicer_to_server(TestService, server) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + + return server + + +def _tear_down(server: grpc.Server): + server.stop(None) + + +def _find_name(request): + return request.__class__ + + +class TestService(gRPCTestServiceServicer): + events = [] + + @staticmethod + def TestServe(request, context): # noqa: N802 + hub = Hub.current + with hub.start_span(op="test", description="test"): + pass + + return gRPCTestMessage(text=request.text) diff --git a/tests/integrations/socket/__init__.py b/tests/integrations/socket/__init__.py new file mode 100644 index 0000000000..893069b21b --- /dev/null +++ b/tests/integrations/socket/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("socket") diff --git a/tests/integrations/socket/test_socket.py b/tests/integrations/socket/test_socket.py new file mode 100644 index 0000000000..914ba0bf84 --- /dev/null +++ b/tests/integrations/socket/test_socket.py @@ -0,0 +1,51 @@ +import socket + +from sentry_sdk import start_transaction +from sentry_sdk.integrations.socket import SocketIntegration + + +def test_getaddrinfo_trace(sentry_init, capture_events): + sentry_init(integrations=[SocketIntegration()], traces_sample_rate=1.0) + events = capture_events() + + with start_transaction(): + socket.getaddrinfo("example.com", 443) + + (event,) = events + (span,) = event["spans"] + + assert span["op"] == "socket.dns" + assert span["description"] == "example.com:443" + assert span["data"] == { + "host": "example.com", + "port": 443, + } + + +def test_create_connection_trace(sentry_init, capture_events): + timeout = 10 + + sentry_init(integrations=[SocketIntegration()], traces_sample_rate=1.0) + events = capture_events() + + with start_transaction(): + socket.create_connection(("example.com", 443), timeout, None) + + (event,) = events + (connect_span, dns_span) = event["spans"] + # as getaddrinfo gets called in create_connection it should also contain a dns span + + assert connect_span["op"] == "socket.connection" + assert connect_span["description"] == "example.com:443" + assert connect_span["data"] == { + "address": ["example.com", 443], + "timeout": timeout, + "source_address": None, + } + + assert dns_span["op"] == "socket.dns" + assert dns_span["description"] == "example.com:443" + assert dns_span["data"] == { + "host": "example.com", + "port": 443, + } diff --git a/tox.ini b/tox.ini index bdae91f817..24d1cd3b40 100644 --- a/tox.ini +++ b/tox.ini @@ -87,6 +87,9 @@ envlist = # GCP {py3.7}-gcp + # Grpc + {py3.7,py3.8,py3.9,py3.10,py3.11}-grpc-v{1.21.1,1.22.1,1.23.1,1.24.1,1.25.0,1.26.0,1.27.1,1.28.1,1.29.0,1.30.0,1.31.0,1.32.0,1.33.1,1.34.0,1.36.0,1.37.0,1.38.0,1.39.0,1.40.0,1.41.1,1.43.0,1.44.0,1.46.1,1.48.1,1.51.3,1.53.0} + # HTTPX {py3.6,py3.7,py3.8,py3.9}-httpx-v{0.16,0.17,0.18} {py3.6,py3.7,py3.8,py3.9,py3.10}-httpx-v{0.19,0.20,0.21,0.22} @@ -151,7 +154,6 @@ envlist = {py3.5,py3.6,py3.7,py3.8,py3.9}-trytond-v{4.6,5.0,5.2} {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-trytond-v{5.4} - [testenv] deps = # if you change test-requirements.txt and your change is not being reflected @@ -292,6 +294,12 @@ deps = py3.5-gevent: greenlet==0.4.17 {py2.7,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0 + # Grpc + grpc: grpcio-tools + grpc: protobuf + grpc: mypy-protobuf + grpc: types-protobuf + # HTTPX httpx: pytest-httpx httpx-v0.16: httpx>=0.16,<0.17 @@ -447,6 +455,8 @@ setenv = sqlalchemy: TESTPATH=tests/integrations/sqlalchemy tornado: TESTPATH=tests/integrations/tornado trytond: TESTPATH=tests/integrations/trytond + socket: TESTPATH=tests/integrations/socket + grpc: TESTPATH=tests/integrations/grpc COVERAGE_FILE=.coverage-{envname} passenv =