Skip to content

Commit 41631e4

Browse files
feat(tracing) Implement an interceptor for grpc server
Implement gRPC ServerInterceptor; starts(or continues) a transaction for each grpc request
1 parent cb107fd commit 41631e4

File tree

13 files changed

+351
-5
lines changed

13 files changed

+351
-5
lines changed

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class OP:
7979
WEBSOCKET_SERVER = "websocket.server"
8080
SOCKET_CONNECTION = "socket.connection"
8181
SOCKET_DNS = "socket.dns"
82+
GRPC_SERVER = "grpc.server"
8283

8384

8485
# This type exists to trick mypy and PyCharm into thinking `init` and `Client`
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .server import ServerInterceptor
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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
9+
10+
try:
11+
import grpc
12+
except ImportError:
13+
raise DidNotEnable("grpcio is not installed")
14+
15+
16+
class ServerInterceptor(grpc.ServerInterceptor):
17+
def __init__(self, find_name=None):
18+
# type: (ServerInterceptor, Callable | None) -> ServerInterceptor
19+
if find_name:
20+
self._find_method_name = find_name
21+
super(ServerInterceptor, self).__init__()
22+
23+
def intercept_service(self, continuation, handler_call_details):
24+
handler = continuation(handler_call_details)
25+
if not handler or not handler.unary_unary:
26+
return handler
27+
28+
def behavior(request, context):
29+
hub = Hub(Hub.current)
30+
31+
name = self._find_method_name(context)
32+
33+
if name:
34+
meta_data = dict(context.invocation_metadata())
35+
36+
transaction = Transaction.continue_from_headers(
37+
meta_data,
38+
op=OP.GRPC_SERVER,
39+
name=name,
40+
source=TRANSACTION_SOURCE_CUSTOM,
41+
)
42+
43+
with hub.start_transaction(transaction=transaction):
44+
try:
45+
return handler.unary_unary(request, context)
46+
except BaseException as e:
47+
raise e
48+
else:
49+
return handler.unary_unary(request, context)
50+
51+
return grpc.unary_unary_rpc_method_handler(
52+
behavior,
53+
request_deserializer=handler.request_deserializer,
54+
response_serializer=handler.response_serializer,
55+
)
56+
57+
@staticmethod
58+
def _find_name(context):
59+
return context._rpc_event.call_details.method.decode()

setup.py

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

test-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ executing
1313
asttokens
1414
responses
1515
ipdb
16+
grpcio-tools
17+
protobuf
18+
mypy-protobuf
19+
types-protobuf

tests/conftest.py

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

301-
return EventStreamReader(events_r)
301+
return EventStreamReader(events_r, events_w)
302302

303303
return inner
304304

305305

306306
class EventStreamReader(object):
307-
def __init__(self, file):
308-
self.file = file
307+
def __init__(self, read_file, write_file):
308+
self.read_file = read_file
309+
self.write_file = write_file
309310

310311
def read_event(self):
311-
return json.loads(self.file.readline().decode("utf-8"))
312+
return json.loads(self.read_file.readline().decode("utf-8"))
312313

313314
def read_flush(self):
314-
assert self.file.readline() == b"flush\n"
315+
assert self.read_file.readline() == b"flush\n"
315316

316317

317318
# scope=session ensures that fixture is run earlier

tests/integrations/grpc/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("grpc")
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import absolute_import
2+
3+
from concurrent import futures
4+
5+
import grpc
6+
import pytest
7+
8+
from sentry_sdk import Hub, start_transaction
9+
from sentry_sdk.consts import OP
10+
from sentry_sdk.integrations.grpc.server import ServerInterceptor
11+
from tests.integrations.grpc.test_service_pb2 import TestMessage
12+
from tests.integrations.grpc.test_service_pb2_grpc import (
13+
TestServiceServicer,
14+
add_TestServiceServicer_to_server,
15+
TestServiceStub,
16+
)
17+
18+
PORT = 50051
19+
20+
21+
@pytest.mark.forked
22+
def test_grpc_server_starts_transaction(sentry_init, capture_events_forksafe):
23+
sentry_init(traces_sample_rate=1.0)
24+
events = capture_events_forksafe()
25+
26+
server = _set_up()
27+
28+
with grpc.insecure_channel(f"localhost:{PORT}") as channel:
29+
stub = TestServiceStub(channel)
30+
stub.TestServe(TestMessage(text="test"))
31+
32+
_tear_down(server=server)
33+
34+
events.write_file.close()
35+
event = events.read_event()
36+
span = event["spans"][0]
37+
38+
assert event["type"] == "transaction"
39+
assert event["transaction_info"] == {
40+
"source": "custom",
41+
}
42+
assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER
43+
assert span["op"] == "test"
44+
45+
46+
@pytest.mark.forked
47+
def test_grpc_server_continues_transaction(sentry_init, capture_events_forksafe):
48+
sentry_init(traces_sample_rate=1.0)
49+
events = capture_events_forksafe()
50+
51+
server = _set_up()
52+
53+
with grpc.insecure_channel(f"localhost:{PORT}") as channel:
54+
stub = TestServiceStub(channel)
55+
56+
with start_transaction() as transaction:
57+
metadata = (
58+
(
59+
"baggage",
60+
"sentry-trace_id={trace_id},sentry-environment=test,"
61+
"sentry-transaction=test-transaction,sentry-sample_rate=1.0".format(
62+
trace_id=transaction.trace_id
63+
),
64+
),
65+
(
66+
"sentry-trace",
67+
"{trace_id}-{parent_span_id}-{sampled}".format(
68+
trace_id=transaction.trace_id,
69+
parent_span_id=transaction.span_id,
70+
sampled=1,
71+
)
72+
)
73+
)
74+
stub.TestServe(TestMessage(text="test"), metadata=metadata)
75+
76+
_tear_down(server=server)
77+
78+
events.write_file.close()
79+
event = events.read_event()
80+
span = event["spans"][0]
81+
82+
assert event["type"] == "transaction"
83+
assert event["transaction_info"] == {
84+
"source": "custom",
85+
}
86+
assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER
87+
assert event["contexts"]["trace"]["trace_id"] == transaction.trace_id
88+
assert span["op"] == "test"
89+
90+
91+
def _set_up():
92+
server = grpc.server(
93+
futures.ThreadPoolExecutor(max_workers=2),
94+
interceptors=[ServerInterceptor(find_name=_find_name)],
95+
)
96+
97+
add_TestServiceServicer_to_server(TestService, server)
98+
server.add_insecure_port(f"[::]:{PORT}")
99+
server.start()
100+
101+
return server
102+
103+
104+
def _tear_down(server: grpc.Server):
105+
server.stop(None)
106+
107+
108+
def _find_name(request):
109+
return request.__class__
110+
111+
112+
class TestService(TestServiceServicer):
113+
events = []
114+
115+
@staticmethod
116+
def TestServe(request, context):
117+
hub = Hub.current
118+
with hub.start_span(op="test", description="test"):
119+
pass
120+
121+
return TestMessage(text=request.text)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
syntax = "proto3";
2+
3+
package test_grpc_server;
4+
5+
service TestService{
6+
rpc TestServe(TestMessage) returns (TestMessage);
7+
}
8+
9+
message TestMessage {
10+
string text = 1;
11+
}

tests/integrations/grpc/test_service_pb2.py

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)