Skip to content

Commit a7f44ac

Browse files
beniwohliamannocci
andauthored
GRPC Instrumentation (#1703)
* initial support for GRPC instrumentation * rename generated GRPC files to avoid false detection by pytest * Correct nightly jobs (#1707) * Support daily snapshots (#1700) Signed-off-by: Adrien Mannocci <[email protected]> * when running run_tests.sh locally, make sure to use the user uid/gid (#1708) * implement core review feedback from @basepi Signed-off-by: Adrien Mannocci <[email protected]> Co-authored-by: Adrien Mannocci <[email protected]>
1 parent e515760 commit a7f44ac

26 files changed

+1152
-10
lines changed

.ci/.jenkins_exclude.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,8 @@ exclude:
185185
# pylibmc
186186
- PYTHON_VERSION: python-3.11
187187
FRAMEWORK: pylibmc-1.4
188+
# grpc
189+
- PYTHON_VERSION: python-3.6
190+
FRAMEWORK: grpc-newest
191+
- PYTHON_VERSION: python-3.6
192+
FRAMEWORK: grpc-1.24

.ci/.jenkins_framework.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,4 @@ FRAMEWORK:
5353
- aiomysql-newest
5454
- aiobotocore-newest
5555
- kafka-python-newest
56+
- grpc-newest

.ci/.jenkins_framework_full.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,5 @@ FRAMEWORK:
8383
- sanic-newest
8484
- aiobotocore-newest
8585
- kafka-python-newest
86+
- grpc-newest
87+
- grpc-1.24

.pre-commit-config.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,24 @@ repos:
33
rev: 5.10.1
44
hooks:
55
- id: isort
6+
exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py|tests/contrib/grpc/grpc_app/.*pb2.*.py)"
67
- repo: https://github.com/ambv/black
78
rev: 22.8.0
89
hooks:
910
- id: black
1011
language_version: python3
11-
exclude: elasticapm\/utils\/wrapt
12+
exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py|tests/contrib/grpc/grpc_app/.*pb2.*.py)"
1213
- repo: https://github.com/PyCQA/flake8
1314
rev: 5.0.4
1415
hooks:
1516
- id: flake8
16-
exclude: elasticapm\/utils\/wrapt|build|src|tests|dist|conftest.py|setup.py
17+
exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py|tests/contrib/grpc/grpc_app/.*pb2.*.py)"
1718
- repo: local
1819
hooks:
1920
- id: license-header-check
2021
name: License header check
2122
description: Checks the existance of license headers in all Python files
2223
entry: ./tests/scripts/license_headers_check.sh
23-
exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py)"
24+
exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py|tests/contrib/grpc/grpc_app/.*pb2.*.py)"
2425
language: script
2526
types: [python]

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ flake8:
99

1010
test:
1111
# delete any __pycache__ folders to avoid hard-to-debug caching issues
12-
find . -name __pycache__ -type d -exec rm -r {} +
12+
find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete
1313
# pypy3 should be added to the first `if` once it supports py3.7
1414
if [[ "$$PYTHON_VERSION" =~ ^(3.7|3.8|3.9|3.10|3.11|nightly)$$ ]] ; then \
1515
echo "Python 3.7+, with asyncio"; \

docs/grpc.asciidoc

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
[[grpc-support]]
2+
=== GRPC Support
3+
4+
Incorporating Elastic APM into your GRPC project only requires a few easy
5+
steps.
6+
7+
NOTE: currently, only unary-unary RPC calls are instrumented. Streaming requests or responses are not captured.
8+
9+
[float]
10+
[[grpc-installation]]
11+
==== Installation
12+
13+
Install the Elastic APM agent using pip:
14+
15+
[source,bash]
16+
----
17+
$ pip install elastic-apm
18+
----
19+
20+
or add `elastic-apm` to your project's `requirements.txt` file.
21+
22+
23+
[float]
24+
[[grpc-setup]]
25+
==== Setup
26+
27+
Elastic APM can be used both in GRPC server apps, and in GRPC client apps.
28+
29+
[float]
30+
[[grpc-setup-client]]
31+
===== GRPC Client
32+
33+
If you use one of our <<framework-support, supported frameworks>>, no further steps are needed.
34+
35+
For other use cases, see <<instrumenting-custom-code-transactions, Creating New Transactions>>.
36+
To ensure that our instrumentation is in place, call `elasticapm.instrument()` *before* creating any GRPC channels.
37+
38+
[float]
39+
[[grpc-setup-server]]
40+
===== GRPC Server
41+
42+
To set up the agent, you need to initialize it with appropriate settings.
43+
44+
The settings are configured either via environment variables, or as
45+
initialization arguments.
46+
47+
You can find a list of all available settings in the
48+
<<configuration, Configuration>> page.
49+
50+
To initialize the agent for your application using environment variables:
51+
52+
[source,python]
53+
----
54+
import elasticapm
55+
from elasticapm.contrib.grpc import GRPCApmClient
56+
57+
elasticapm.instrument()
58+
59+
client = GRPCApmClient(service_name="my-grpc-server")
60+
----
61+
62+
63+
Once you have configured the agent, it will automatically track transactions
64+
and capture uncaught exceptions within GRPC requests.
65+

docs/supported-technologies.asciidoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The Elastic APM Python Agent comes with support for the following frameworks:
1010
* <<supported-tornado,Tornado>>
1111
* <<supported-starlette,Starlette/FastAPI>>
1212
* <<supported-sanic,Sanic>>
13+
* <<supported-grpc,GRPC>>
1314

1415
For other frameworks and custom Python code, the agent exposes a set of <<api,APIs>> for integration.
1516

@@ -95,6 +96,15 @@ We support these Starlette versions:
9596
Any FastAPI version which uses a supported Starlette version should also
9697
be supported.
9798

99+
[float]
100+
[[supported-grpc]]
101+
=== GRPC
102+
103+
We support these `grpcio` versions:
104+
105+
* 1.24.0+
106+
107+
98108
[float]
99109
[[automatic-instrumentation]]
100110
== Automatic Instrumentation
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# BSD 3-Clause License
2+
#
3+
# Copyright (c) 2022, Elasticsearch BV
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
31+
from elasticapm import Client
32+
33+
34+
class GRPCApmClient(Client):
35+
def __init__(self, *args, **kwargs):
36+
import grpc
37+
38+
kwargs["framework_name"] = "grpc"
39+
kwargs["framework_version"] = grpc.__version__
40+
super().__init__(*args, **kwargs)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# BSD 3-Clause License
2+
#
3+
# Copyright (c) 2022, Elasticsearch BV
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
31+
from typing import Optional
32+
33+
import grpc
34+
from grpc._interceptor import _ClientCallDetails
35+
36+
import elasticapm
37+
from elasticapm.conf import constants
38+
from elasticapm.traces import Span
39+
from elasticapm.utils import default_ports
40+
41+
42+
class _ClientInterceptor(
43+
grpc.UnaryUnaryClientInterceptor,
44+
# grpc.UnaryStreamClientInterceptor,
45+
# grpc.StreamUnaryClientInterceptor,
46+
# grpc.StreamStreamClientInterceptor,
47+
):
48+
def __init__(self, host: Optional[str], port: Optional[str], secure: bool):
49+
self.host: str = host
50+
self.port: str = port
51+
self.secure: bool = secure
52+
schema = "https" if secure else "http"
53+
resource = f"{schema}://{host}"
54+
if port and int(port) != default_ports[schema]:
55+
resource += f":{port}"
56+
57+
self._context = {
58+
"http": {
59+
"url": resource,
60+
},
61+
"destination": {
62+
"address": host,
63+
"port": port,
64+
},
65+
}
66+
67+
def intercept_unary_unary(self, continuation, client_call_details, request):
68+
"""Intercepts a unary-unary invocation asynchronously.
69+
70+
Args:
71+
continuation: A function that proceeds with the invocation by
72+
executing the next interceptor in chain or invoking the
73+
actual RPC on the underlying Channel. It is the interceptor's
74+
responsibility to call it if it decides to move the RPC forward.
75+
The interceptor can use
76+
`response_future = continuation(client_call_details, request)`
77+
to continue with the RPC. `continuation` returns an object that is
78+
both a Call for the RPC and a Future. In the event of RPC
79+
completion, the return Call-Future's result value will be
80+
the response message of the RPC. Should the event terminate
81+
with non-OK status, the returned Call-Future's exception value
82+
will be an RpcError.
83+
client_call_details: A ClientCallDetails object describing the
84+
outgoing RPC.
85+
request: The request value for the RPC.
86+
87+
Returns:
88+
An object that is both a Call for the RPC and a Future.
89+
In the event of RPC completion, the return Call-Future's
90+
result value will be the response message of the RPC.
91+
Should the event terminate with non-OK status, the returned
92+
Call-Future's exception value will be an RpcError.
93+
"""
94+
with elasticapm.capture_span(
95+
client_call_details.method, span_type="external", span_subtype="grpc", extra=self._context.copy(), leaf=True
96+
) as span:
97+
client_call_details = self.attach_traceparent(client_call_details, span)
98+
try:
99+
response = continuation(client_call_details, request)
100+
except grpc.RpcError:
101+
span.set_failure()
102+
raise
103+
104+
return response
105+
106+
# TODO: instrument other types of requests once the spec is ready
107+
108+
# def intercept_unary_stream(self, continuation, client_call_details,
109+
# request):
110+
# """Intercepts a unary-stream invocation.
111+
#
112+
# Args:
113+
# continuation: A function that proceeds with the invocation by
114+
# executing the next interceptor in chain or invoking the
115+
# actual RPC on the underlying Channel. It is the interceptor's
116+
# responsibility to call it if it decides to move the RPC forward.
117+
# The interceptor can use
118+
# `response_iterator = continuation(client_call_details, request)`
119+
# to continue with the RPC. `continuation` returns an object that is
120+
# both a Call for the RPC and an iterator for response values.
121+
# Drawing response values from the returned Call-iterator may
122+
# raise RpcError indicating termination of the RPC with non-OK
123+
# status.
124+
# client_call_details: A ClientCallDetails object describing the
125+
# outgoing RPC.
126+
# request: The request value for the RPC.
127+
#
128+
# Returns:
129+
# An object that is both a Call for the RPC and an iterator of
130+
# response values. Drawing response values from the returned
131+
# Call-iterator may raise RpcError indicating termination of
132+
# the RPC with non-OK status. This object *should* also fulfill the
133+
# Future interface, though it may not.
134+
# """
135+
# response_iterator = continuation(client_call_details, request)
136+
# return response_iterator
137+
#
138+
# def intercept_stream_unary(self, continuation, client_call_details,
139+
# request_iterator):
140+
# """Intercepts a stream-unary invocation asynchronously.
141+
#
142+
# Args:
143+
# continuation: A function that proceeds with the invocation by
144+
# executing the next interceptor in chain or invoking the
145+
# actual RPC on the underlying Channel. It is the interceptor's
146+
# responsibility to call it if it decides to move the RPC forward.
147+
# The interceptor can use
148+
# `response_future = continuation(client_call_details, request_iterator)`
149+
# to continue with the RPC. `continuation` returns an object that is
150+
# both a Call for the RPC and a Future. In the event of RPC completion,
151+
# the return Call-Future's result value will be the response message
152+
# of the RPC. Should the event terminate with non-OK status, the
153+
# returned Call-Future's exception value will be an RpcError.
154+
# client_call_details: A ClientCallDetails object describing the
155+
# outgoing RPC.
156+
# request_iterator: An iterator that yields request values for the RPC.
157+
#
158+
# Returns:
159+
# An object that is both a Call for the RPC and a Future.
160+
# In the event of RPC completion, the return Call-Future's
161+
# result value will be the response message of the RPC.
162+
# Should the event terminate with non-OK status, the returned
163+
# Call-Future's exception value will be an RpcError.
164+
# """
165+
#
166+
# def intercept_stream_stream(self, continuation, client_call_details,
167+
# request_iterator):
168+
# """Intercepts a stream-stream invocation.
169+
#
170+
# Args:
171+
# continuation: A function that proceeds with the invocation by
172+
# executing the next interceptor in chain or invoking the
173+
# actual RPC on the underlying Channel. It is the interceptor's
174+
# responsibility to call it if it decides to move the RPC forward.
175+
# The interceptor can use
176+
# `response_iterator = continuation(client_call_details, request_iterator)`
177+
# to continue with the RPC. `continuation` returns an object that is
178+
# both a Call for the RPC and an iterator for response values.
179+
# Drawing response values from the returned Call-iterator may
180+
# raise RpcError indicating termination of the RPC with non-OK
181+
# status.
182+
# client_call_details: A ClientCallDetails object describing the
183+
# outgoing RPC.
184+
# request_iterator: An iterator that yields request values for the RPC.
185+
#
186+
# Returns:
187+
# An object that is both a Call for the RPC and an iterator of
188+
# response values. Drawing response values from the returned
189+
# Call-iterator may raise RpcError indicating termination of
190+
# the RPC with non-OK status. This object *should* also fulfill the
191+
# Future interface, though it may not.
192+
# """
193+
194+
def attach_traceparent(self, client_call_details: _ClientCallDetails, span: Span):
195+
if not span.transaction:
196+
return client_call_details
197+
meta = list(client_call_details.metadata) if client_call_details.metadata else []
198+
if constants.TRACEPARENT_HEADER_NAME not in meta:
199+
traceparent = span.transaction.trace_parent.copy_from(span_id=span.id)
200+
meta.extend(
201+
(
202+
(constants.TRACEPARENT_HEADER_NAME, traceparent.to_string()),
203+
(constants.TRACESTATE_HEADER_NAME, traceparent.tracestate),
204+
)
205+
)
206+
return client_call_details._replace(metadata=meta)

0 commit comments

Comments
 (0)