Skip to content

Commit 5d9cd4f

Browse files
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 <[email protected]>
1 parent b98d727 commit 5d9cd4f

19 files changed

+734
-6
lines changed

.flake8

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ extend-ignore =
1515
# is a worse version of and conflicts with B902 (first argument of a classmethod should be named cls)
1616
N804,
1717
extend-exclude=checkouts,lol*
18+
exclude =
19+
# gRCP generated files
20+
grpc_test_service_pb2.py
21+
grpc_test_service_pb2_grpc.py
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Test grpc
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- release/**
8+
9+
pull_request:
10+
11+
# Cancel in progress workflows on pull_requests.
12+
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
BUILD_CACHE_KEY: ${{ github.sha }}
22+
CACHED_BUILD_PATHS: |
23+
${{ github.workspace }}/dist-serverless
24+
25+
jobs:
26+
test:
27+
name: grpc, python ${{ matrix.python-version }}, ${{ matrix.os }}
28+
runs-on: ${{ matrix.os }}
29+
timeout-minutes: 45
30+
31+
strategy:
32+
fail-fast: false
33+
matrix:
34+
python-version: ["3.7","3.8","3.9","3.10","3.11"]
35+
# python3.6 reached EOL and is no longer being supported on
36+
# new versions of hosted runners on Github Actions
37+
# ubuntu-20.04 is the last version that supported python3.6
38+
# see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877
39+
os: [ubuntu-20.04]
40+
41+
steps:
42+
- uses: actions/checkout@v3
43+
- uses: actions/setup-python@v4
44+
with:
45+
python-version: ${{ matrix.python-version }}
46+
47+
- name: Setup Test Env
48+
run: |
49+
pip install codecov "tox>=3,<4"
50+
51+
- name: Test grpc
52+
timeout-minutes: 45
53+
shell: bash
54+
run: |
55+
set -x # print commands that are executed
56+
coverage erase
57+
58+
./scripts/runtox.sh "py${{ matrix.python-version }}-grpc" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
59+
coverage combine .coverage*
60+
coverage xml -i
61+
codecov --file coverage.xml
62+
63+
check_required_tests:
64+
name: All grpc tests passed or skipped
65+
needs: test
66+
# Always run this, even if a dependent job failed
67+
if: always()
68+
runs-on: ubuntu-20.04
69+
steps:
70+
- name: Check for failures
71+
if: contains(needs.test.result, 'failure')
72+
run: |
73+
echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1

mypy.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,5 @@ ignore_missing_imports = True
6767
ignore_missing_imports = True
6868
[mypy-arq.*]
6969
ignore_missing_imports = True
70+
[mypy-grpc.*]
71+
ignore_missing_imports = True

sentry_sdk/consts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ class OP:
5959
FUNCTION = "function"
6060
FUNCTION_AWS = "function.aws"
6161
FUNCTION_GCP = "function.gcp"
62+
GRPC_CLIENT = "grpc.client"
63+
GRPC_SERVER = "grpc.server"
6264
HTTP_CLIENT = "http.client"
6365
HTTP_CLIENT_STREAM = "http.client.stream"
6466
HTTP_SERVER = "http.server"
@@ -83,6 +85,8 @@ class OP:
8385
VIEW_RENDER = "view.render"
8486
VIEW_RESPONSE_RENDER = "view.response.render"
8587
WEBSOCKET_SERVER = "websocket.server"
88+
SOCKET_CONNECTION = "socket.connection"
89+
SOCKET_DNS = "socket.dns"
8690

8791

8892
# This type exists to trick mypy and PyCharm into thinking `init` and `Client`
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .server import ServerInterceptor # noqa: F401
2+
from .client import ClientInterceptor # noqa: F401
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from sentry_sdk import Hub
2+
from sentry_sdk._types import MYPY
3+
from sentry_sdk.consts import OP
4+
from sentry_sdk.integrations import DidNotEnable
5+
6+
if MYPY:
7+
from typing import Any, Callable, Iterator, Iterable, Union
8+
9+
try:
10+
import grpc
11+
from grpc import ClientCallDetails, Call
12+
from grpc._interceptor import _UnaryOutcome
13+
from grpc.aio._interceptor import UnaryStreamCall
14+
from google.protobuf.message import Message # type: ignore
15+
except ImportError:
16+
raise DidNotEnable("grpcio is not installed")
17+
18+
19+
class ClientInterceptor(
20+
grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor # type: ignore
21+
):
22+
def intercept_unary_unary(self, continuation, client_call_details, request):
23+
# type: (ClientInterceptor, Callable[[ClientCallDetails, Message], _UnaryOutcome], ClientCallDetails, Message) -> _UnaryOutcome
24+
hub = Hub.current
25+
method = client_call_details.method
26+
27+
with hub.start_span(
28+
op=OP.GRPC_CLIENT, description="unary unary call to %s" % method
29+
) as span:
30+
span.set_data("type", "unary unary")
31+
span.set_data("method", method)
32+
33+
client_call_details = self._update_client_call_details_metadata_from_hub(
34+
client_call_details, hub
35+
)
36+
37+
response = continuation(client_call_details, request)
38+
span.set_data("code", response.code().name)
39+
40+
return response
41+
42+
def intercept_unary_stream(self, continuation, client_call_details, request):
43+
# type: (ClientInterceptor, Callable[[ClientCallDetails, Message], Union[Iterable[Any], UnaryStreamCall]], ClientCallDetails, Message) -> Union[Iterator[Message], Call]
44+
hub = Hub.current
45+
method = client_call_details.method
46+
47+
with hub.start_span(
48+
op=OP.GRPC_CLIENT, description="unary stream call to %s" % method
49+
) as span:
50+
span.set_data("type", "unary stream")
51+
span.set_data("method", method)
52+
53+
client_call_details = self._update_client_call_details_metadata_from_hub(
54+
client_call_details, hub
55+
)
56+
57+
response = continuation(
58+
client_call_details, request
59+
) # type: UnaryStreamCall
60+
span.set_data("code", response.code().name)
61+
62+
return response
63+
64+
@staticmethod
65+
def _update_client_call_details_metadata_from_hub(client_call_details, hub):
66+
# type: (ClientCallDetails, Hub) -> ClientCallDetails
67+
metadata = (
68+
list(client_call_details.metadata) if client_call_details.metadata else []
69+
)
70+
for key, value in hub.iter_trace_propagation_headers():
71+
metadata.append((key, value))
72+
73+
client_call_details = grpc._interceptor._ClientCallDetails(
74+
method=client_call_details.method,
75+
timeout=client_call_details.timeout,
76+
metadata=metadata,
77+
credentials=client_call_details.credentials,
78+
wait_for_ready=client_call_details.wait_for_ready,
79+
compression=client_call_details.compression,
80+
)
81+
82+
return client_call_details
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from sentry_sdk import Hub
2+
from sentry_sdk._types import MYPY
3+
from sentry_sdk.consts import OP
4+
from sentry_sdk.integrations import DidNotEnable
5+
from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM
6+
7+
if MYPY:
8+
from typing import Callable, Optional
9+
from google.protobuf.message import Message # type: ignore
10+
11+
try:
12+
import grpc
13+
from grpc import ServicerContext, HandlerCallDetails, RpcMethodHandler
14+
except ImportError:
15+
raise DidNotEnable("grpcio is not installed")
16+
17+
18+
class ServerInterceptor(grpc.ServerInterceptor): # type: ignore
19+
def __init__(self, find_name=None):
20+
# type: (ServerInterceptor, Optional[Callable[[ServicerContext], str]]) -> None
21+
self._find_method_name = find_name or ServerInterceptor._find_name
22+
23+
super(ServerInterceptor, self).__init__()
24+
25+
def intercept_service(self, continuation, handler_call_details):
26+
# type: (ServerInterceptor, Callable[[HandlerCallDetails], RpcMethodHandler], HandlerCallDetails) -> RpcMethodHandler
27+
handler = continuation(handler_call_details)
28+
if not handler or not handler.unary_unary:
29+
return handler
30+
31+
def behavior(request, context):
32+
# type: (Message, ServicerContext) -> Message
33+
hub = Hub(Hub.current)
34+
35+
name = self._find_method_name(context)
36+
37+
if name:
38+
metadata = dict(context.invocation_metadata())
39+
40+
transaction = Transaction.continue_from_headers(
41+
metadata,
42+
op=OP.GRPC_SERVER,
43+
name=name,
44+
source=TRANSACTION_SOURCE_CUSTOM,
45+
)
46+
47+
with hub.start_transaction(transaction=transaction):
48+
try:
49+
return handler.unary_unary(request, context)
50+
except BaseException as e:
51+
raise e
52+
else:
53+
return handler.unary_unary(request, context)
54+
55+
return grpc.unary_unary_rpc_method_handler(
56+
behavior,
57+
request_deserializer=handler.request_deserializer,
58+
response_serializer=handler.response_serializer,
59+
)
60+
61+
@staticmethod
62+
def _find_name(context):
63+
# type: (ServicerContext) -> str
64+
return context._rpc_event.call_details.method.decode()

sentry_sdk/integrations/socket.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import socket
2+
from sentry_sdk import Hub
3+
from sentry_sdk._types import MYPY
4+
from sentry_sdk.consts import OP
5+
from sentry_sdk.integrations import Integration
6+
7+
if MYPY:
8+
from socket import AddressFamily, SocketKind
9+
from typing import Tuple, Optional, Union, List
10+
11+
__all__ = ["SocketIntegration"]
12+
13+
14+
class SocketIntegration(Integration):
15+
identifier = "socket"
16+
17+
@staticmethod
18+
def setup_once():
19+
# type: () -> None
20+
"""
21+
patches two of the most used functions of socket: create_connection and getaddrinfo(dns resolver)
22+
"""
23+
_patch_create_connection()
24+
_patch_getaddrinfo()
25+
26+
27+
def _get_span_description(host, port):
28+
# type: (Union[bytes, str, None], Union[str, int, None]) -> str
29+
30+
try:
31+
host = host.decode() # type: ignore
32+
except (UnicodeDecodeError, AttributeError):
33+
pass
34+
35+
description = "%s:%s" % (host, port) # type: ignore
36+
37+
return description
38+
39+
40+
def _patch_create_connection():
41+
# type: () -> None
42+
real_create_connection = socket.create_connection
43+
44+
def create_connection(
45+
address,
46+
timeout=socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore
47+
source_address=None,
48+
):
49+
# type: (Tuple[Optional[str], int], Optional[float], Optional[Tuple[Union[bytearray, bytes, str], int]])-> socket.socket
50+
hub = Hub.current
51+
if hub.get_integration(SocketIntegration) is None:
52+
return real_create_connection(
53+
address=address, timeout=timeout, source_address=source_address
54+
)
55+
56+
with hub.start_span(
57+
op=OP.SOCKET_CONNECTION,
58+
description=_get_span_description(address[0], address[1]),
59+
) as span:
60+
span.set_data("address", address)
61+
span.set_data("timeout", timeout)
62+
span.set_data("source_address", source_address)
63+
64+
return real_create_connection(
65+
address=address, timeout=timeout, source_address=source_address
66+
)
67+
68+
socket.create_connection = create_connection
69+
70+
71+
def _patch_getaddrinfo():
72+
# type: () -> None
73+
real_getaddrinfo = socket.getaddrinfo
74+
75+
def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
76+
# 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]]]]
77+
hub = Hub.current
78+
if hub.get_integration(SocketIntegration) is None:
79+
return real_getaddrinfo(host, port, family, type, proto, flags)
80+
81+
with hub.start_span(
82+
op=OP.SOCKET_DNS, description=_get_span_description(host, port)
83+
) as span:
84+
span.set_data("host", host)
85+
span.set_data("port", port)
86+
87+
return real_getaddrinfo(host, port, family, type, proto, flags)
88+
89+
socket.getaddrinfo = getaddrinfo

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def get_file_text(file_name):
6767
"fastapi": ["fastapi>=0.79.0"],
6868
"pymongo": ["pymongo>=3.1"],
6969
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
70+
"grpcio": ["grpcio>=1.21.1"]
7071
},
7172
classifiers=[
7273
"Development Status :: 5 - Production/Stable",

tests/conftest.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,20 +311,21 @@ def flush(timeout=None, callback=None):
311311
monkeypatch.setattr(test_client.transport, "capture_event", append)
312312
monkeypatch.setattr(test_client, "flush", flush)
313313

314-
return EventStreamReader(events_r)
314+
return EventStreamReader(events_r, events_w)
315315

316316
return inner
317317

318318

319319
class EventStreamReader(object):
320-
def __init__(self, file):
321-
self.file = file
320+
def __init__(self, read_file, write_file):
321+
self.read_file = read_file
322+
self.write_file = write_file
322323

323324
def read_event(self):
324-
return json.loads(self.file.readline().decode("utf-8"))
325+
return json.loads(self.read_file.readline().decode("utf-8"))
325326

326327
def read_flush(self):
327-
assert self.file.readline() == b"flush\n"
328+
assert self.read_file.readline() == b"flush\n"
328329

329330

330331
# scope=session ensures that fixture is run earlier

0 commit comments

Comments
 (0)