Skip to content

Commit bf0884d

Browse files
authored
Add minimal OpenTelemetry instrumentation (#150)
1 parent 551b322 commit bf0884d

File tree

5 files changed

+127
-3
lines changed

5 files changed

+127
-3
lines changed

elastic_transport/_node/_http_urllib3.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
try:
2525
from importlib import metadata
2626
except ImportError:
27-
import importlib_metadata as metadata # type: ignore[import-not-found,no-redef]
27+
import importlib_metadata as metadata # type: ignore[no-redef]
2828

2929
import urllib3
3030
from urllib3.exceptions import ConnectTimeoutError, NewConnectionError, ReadTimeoutError

elastic_transport/_otel.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Licensed to Elasticsearch B.V. under one or more contributor
2+
# license agreements. See the NOTICE file distributed with
3+
# this work for additional information regarding copyright
4+
# ownership. Elasticsearch B.V. licenses this file to you under
5+
# the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
import contextlib
21+
import os
22+
import typing
23+
24+
try:
25+
from opentelemetry import trace
26+
27+
_tracer: trace.Tracer | None = trace.get_tracer("elastic-transport")
28+
except ModuleNotFoundError:
29+
_tracer = None
30+
31+
32+
ENABLED_ENV_VAR = "OTEL_PYTHON_INSTRUMENTATION_ELASTICSEARCH_ENABLED"
33+
34+
35+
class OpenTelemetry:
36+
def __init__(self, enabled: bool | None = None, tracer: trace.Tracer | None = None):
37+
if enabled is None:
38+
enabled = os.environ.get(ENABLED_ENV_VAR, "false") != "false"
39+
self.tracer = tracer or _tracer
40+
self.enabled = enabled and self.tracer is not None
41+
42+
@contextlib.contextmanager
43+
def span(self, method: str) -> typing.Generator[None, None, None]:
44+
if not self.enabled or self.tracer is None:
45+
yield
46+
return
47+
48+
span_name = method
49+
with self.tracer.start_as_current_span(span_name) as span:
50+
span.set_attribute("http.request.method", method)
51+
span.set_attribute("db.system", "elasticsearch")
52+
yield

elastic_transport/_transport.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
Urllib3HttpNode,
6161
)
6262
from ._node_pool import NodePool, NodeSelector
63+
from ._otel import OpenTelemetry
6364
from ._serializer import DEFAULT_SERIALIZERS, Serializer, SerializerCollection
6465
from ._version import __version__
6566
from .client_utils import client_meta_version, resolve_default
@@ -225,6 +226,9 @@ def __init__(
225226
self.retry_on_status = retry_on_status
226227
self.retry_on_timeout = retry_on_timeout
227228

229+
# Instrumentation
230+
self.otel = OpenTelemetry()
231+
228232
# Build the NodePool from all the options
229233
node_pool_kwargs: Dict[str, Any] = {}
230234
if node_selector_class is not None:
@@ -252,7 +256,7 @@ def __init__(
252256
if sniff_on_start:
253257
self.sniff(True)
254258

255-
def perform_request( # type: ignore[return]
259+
def perform_request(
256260
self,
257261
method: str,
258262
target: str,
@@ -289,6 +293,32 @@ def perform_request( # type: ignore[return]
289293
:arg client_meta: Extra client metadata key-value pairs to send in the client meta header.
290294
:returns: Tuple of the :class:`elastic_transport.ApiResponseMeta` with the deserialized response.
291295
"""
296+
with self.otel.span(method):
297+
return self._perform_request(
298+
method,
299+
target,
300+
body=body,
301+
headers=headers,
302+
max_retries=max_retries,
303+
retry_on_status=retry_on_status,
304+
retry_on_timeout=retry_on_timeout,
305+
request_timeout=request_timeout,
306+
client_meta=client_meta,
307+
)
308+
309+
def _perform_request( # type: ignore[return]
310+
self,
311+
method: str,
312+
target: str,
313+
*,
314+
body: Optional[Any] = None,
315+
headers: Union[Mapping[str, Any], DefaultType] = DEFAULT,
316+
max_retries: Union[int, DefaultType] = DEFAULT,
317+
retry_on_status: Union[Collection[int], DefaultType] = DEFAULT,
318+
retry_on_timeout: Union[bool, DefaultType] = DEFAULT,
319+
request_timeout: Union[Optional[float], DefaultType] = DEFAULT,
320+
client_meta: Union[Tuple[Tuple[str, str], ...], DefaultType] = DEFAULT,
321+
) -> TransportApiResponse:
292322
if headers is DEFAULT:
293323
request_headers = HttpHeaders()
294324
else:

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
install_requires=[
5353
"urllib3>=1.26.2, <3",
5454
"certifi",
55-
"dataclasses; python_version<'3.7'",
5655
"importlib-metadata; python_version<'3.8'",
5756
],
5857
python_requires=">=3.7",
@@ -69,6 +68,8 @@
6968
"aiohttp",
7069
"httpx",
7170
"respx",
71+
"opentelemetry-api",
72+
"opentelemetry-sdk",
7273
# Override Read the Docs default (sphinx<2)
7374
"sphinx>2",
7475
"furo",

tests/test_otel.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Licensed to Elasticsearch B.V. under one or more contributor
2+
# license agreements. See the NOTICE file distributed with
3+
# this work for additional information regarding copyright
4+
# ownership. Elasticsearch B.V. licenses this file to you under
5+
# the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
19+
from opentelemetry.sdk.trace import TracerProvider, export
20+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
21+
22+
from elastic_transport._otel import OpenTelemetry
23+
24+
25+
def test_span():
26+
tracer_provider = TracerProvider()
27+
memory_exporter = InMemorySpanExporter()
28+
span_processor = export.SimpleSpanProcessor(memory_exporter)
29+
tracer_provider.add_span_processor(span_processor)
30+
tracer = tracer_provider.get_tracer(__name__)
31+
32+
otel = OpenTelemetry(enabled=True, tracer=tracer)
33+
with otel.span("GET"):
34+
pass
35+
36+
spans = memory_exporter.get_finished_spans()
37+
assert len(spans) == 1
38+
assert spans[0].attributes == {
39+
"http.request.method": "GET",
40+
"db.system": "elasticsearch",
41+
}

0 commit comments

Comments
 (0)