From 539b46bd3e1ca2615e53c740ae03caa13bdb27e0 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Thu, 1 Apr 2021 22:27:53 -0700 Subject: [PATCH 01/20] Add support for HTTPX instrumentation This builds off of the initial idea in [this issue][1] of using the transport API in `httpx`. This allows for using [custom transports][2]. One issue with this current implementation is that there are few more [changes coming][3] in 0.18.0 that this will probably want to use. So maybe it will make sense to finalize this on that version before releasing it. Want to get some eyes on this sooner. Resolves #263 [1]: https://github.com/encode/httpx/issues/1264#issuecomment-732034750 [2]: https://www.python-httpx.org/advanced/#custom-transports [3]: https://github.com/encode/httpx/pull/1522 --- docs-requirements.txt | 1 + docs/instrumentation/httpx/httpx.rst | 10 + docs/nitpick-exceptions.ini | 4 + .../LICENSE | 201 ++++++ .../MANIFEST.in | 9 + .../README.rst | 96 +++ .../setup.cfg | 57 ++ .../setup.py | 26 + .../instrumentation/httpx/__init__.py | 325 ++++++++++ .../instrumentation/httpx/version.py | 15 + .../tests/__init__.py | 0 .../tests/test_httpx_integration.py | 609 ++++++++++++++++++ tox.ini | 8 + 13 files changed, 1361 insertions(+) create mode 100644 docs/instrumentation/httpx/httpx.rst create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/LICENSE create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/MANIFEST.in create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/README.rst create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/setup.cfg create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/setup.py create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/tests/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py diff --git a/docs-requirements.txt b/docs-requirements.txt index 5dafd0e0eb..4150e3cac7 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -34,3 +34,4 @@ redis>=2.6 sqlalchemy>=1.0 tornado>=6.0 ddtrace>=0.34.0 +httpx~=0.17.0 \ No newline at end of file diff --git a/docs/instrumentation/httpx/httpx.rst b/docs/instrumentation/httpx/httpx.rst new file mode 100644 index 0000000000..f816539f5b --- /dev/null +++ b/docs/instrumentation/httpx/httpx.rst @@ -0,0 +1,10 @@ +.. include:: ../../../instrumentation/opentelemetry-instrumentation-httpx/README.rst + :end-before: References + +API +--- + +.. automodule:: opentelemetry.instrumentation.httpx + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/nitpick-exceptions.ini b/docs/nitpick-exceptions.ini index 268763e0c6..bb636af132 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -18,6 +18,10 @@ class_references= Setter Getter ; - AwsXRayFormat.extract + httpcore.SyncHTTPTransport + httpcore.AsyncHTTPTransport + httpcore.SyncByteStream + httpcore.AsyncByteStream anys= ; API diff --git a/instrumentation/opentelemetry-instrumentation-httpx/LICENSE b/instrumentation/opentelemetry-instrumentation-httpx/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-httpx/MANIFEST.in b/instrumentation/opentelemetry-instrumentation-httpx/MANIFEST.in new file mode 100644 index 0000000000..aed3e33273 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/MANIFEST.in @@ -0,0 +1,9 @@ +graft src +graft tests +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__/* +include CHANGELOG.md +include MANIFEST.in +include README.rst +include LICENSE diff --git a/instrumentation/opentelemetry-instrumentation-httpx/README.rst b/instrumentation/opentelemetry-instrumentation-httpx/README.rst new file mode 100644 index 0000000000..95c4b26bf9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -0,0 +1,96 @@ +OpenTelemetry HTTPX Instrumentation +=================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-httpx.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-httpx/ + +This library allows tracing HTTP requests made by the +`httpx `_ library. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-httpx + + +Usage +----- + +Instrumenting all clients +************************* + +When using the instrumentor, all newly created clients will automatically trace +requests. + +.. code-block:: python + + import httpx + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + url = "https://httpbin.org/get" + HTTPXClientInstrumentor().instrument() + + with httpx.Client() as client: + response = client.get(url) + + async with httpx.AsyncClient() as client: + response = await client.get(url) + +Uninstrument +^^^^^^^^^^^^ + +If you need to uninstrument clients, there are two options available. + +.. code-block:: python + + import httpx + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + HTTPXClientInstrumentor().instrument() + client = httpx.Client() + + # Uninstrument a specific client + HTTPXClientInstrumentor.uninstrument_client(client) + + # Uninstrument all new clients + HTTPXClientInstrumentor().uninstrument() + + +Instrumenting single clients +**************************** + +If you only want to instrument requests for specific client instances, you can +create the transport instance manually and pass it in when creating the client. + + +.. code-block:: python + + import httpx + from opentelemetry.instrumentation.httpx import ( + AsyncOpenTelemetryTransport, + SyncOpenTelemetryTransport, + ) + + url = "https://httpbin.org/get" + transport = httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport(transport) + + with httpx.Client(transport=telemetry_transport) as client: + response = client.get(url) + + transport = httpx.AsyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport(transport) + + async with httpx.AsyncClient(transport=telemetry_transport) as client: + response = await client.get(url) + + +References +---------- + +* `OpenTelemetry HTTPX Instrumentation `_ +* `OpenTelemetry Project `_ diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg new file mode 100644 index 0000000000..e0d54f14b6 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -0,0 +1,57 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +[metadata] +name = opentelemetry-instrumentation-httpx +description = OpenTelemetry HTTPX Instrumentation +long_description = file: README.rst +long_description_content_type = text/x-rst +author = OpenTelemetry Authors +author_email = cncf-opentelemetry-contributors@lists.cncf.io +url = https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-httpx +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 4 - Beta + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + +[options] +python_requires = >=3.6 +package_dir= + =src +packages=find_namespace: +install_requires = + opentelemetry-api == 1.0.1.dev0 + opentelemetry-instrumentation == 0.20.dev0 + httpx >= 0.17.0, < 0.18.0 + wrapt >= 1.0.0, < 2.0.0 + +[options.extras_require] +test = + opentelemetry-sdk == 1.0.1dev0 + opentelemetry-test == 0.20.dev0 + respx ~= 0.16.0 + +[options.packages.find] +where = src + +[options.entry_points] +opentelemetry_instrumentor = + httpx = opentelemetry.instrumentation.httpx:HTTPXClientInstrumentor diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.py b/instrumentation/opentelemetry-instrumentation-httpx/setup.py new file mode 100644 index 0000000000..4c42ce73f8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.py @@ -0,0 +1,26 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os + +import setuptools + +BASE_DIR = os.path.dirname(__file__) +VERSION_FILENAME = os.path.join( + BASE_DIR, "src", "opentelemetry", "instrumentation", "httpx", "version.py", +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup(version=PACKAGE_INFO["__version__"]) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py new file mode 100644 index 0000000000..72923d65a4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -0,0 +1,325 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import httpcore +import httpx +import wrapt + +from opentelemetry import context +from opentelemetry.instrumentation.httpx.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import ( + http_status_to_status_code, + unwrap, +) +from opentelemetry.propagate import inject +from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer +from opentelemetry.trace.span import Span +from opentelemetry.trace.status import Status + +ResponseInfo = typing.Tuple[ + int, typing.List[typing.Tuple[bytes, bytes]], typing.Iterable[bytes], dict, +] +NameCallback = typing.Callable[[str, str], str] +SpanCallback = typing.Callable[[Span, ResponseInfo], None] +URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes] +Headers = typing.List[typing.Tuple[bytes, bytes]] + + +def _get_tracer( + *, tracer_provider: typing.Optional[TracerProvider] = None +) -> Tracer: + return get_tracer( + __name__, + instrumenting_library_version=__version__, + tracer_provider=tracer_provider, + ) + + +def _get_span_name( + method: str, + url: str, + *, + name_callback: typing.Optional[NameCallback] = None +) -> str: + span_name = "" + if name_callback is not None: + span_name = name_callback(method, url) + if not span_name or not isinstance(span_name, str): + span_name = "HTTP {}".format(method).strip() + return span_name + + +def _apply_status_code(span: Span, status_code: int) -> None: + if not span.is_recording(): + return + + span.set_attribute("http.status_code", status_code) + span.set_status(Status(http_status_to_status_code(status_code))) + + +def _prepare_attributes(method: bytes, url: URL) -> typing.Dict[str, str]: + _method = method.decode().upper() + _url = str(httpx.URL(url)) + span_attributes = { + "http.method": _method, + "http.url": _url, + } + return span_attributes + + +def _prepare_headers(headers: typing.Optional[Headers]) -> httpx.Headers: + return httpx.Headers(headers) + + +class SyncOpenTelemetryTransport(httpcore.SyncHTTPTransport): + """Sync transport class that will trace all requests made with a client. + + Args: + transport: SyncHTTPTransport instance to wrap + tracer_provider: Tracer provider to use + span_callback: A callback provided with the response info to modify + the span + name_callback: A callback provided with the method and url to process + the span name + """ + + def __init__( + self, + transport: httpcore.SyncHTTPTransport, + tracer_provider: typing.Optional[TracerProvider] = None, + span_callback: typing.Optional[SpanCallback] = None, + name_callback: typing.Optional[NameCallback] = None, + ): + self._transport = transport + self._tracer_provider = tracer_provider + self._span_callback = span_callback + self._name_callback = name_callback + + def request( + self, + method: bytes, + url: URL, + headers: typing.Optional[Headers] = None, + stream: typing.Optional[httpcore.SyncByteStream] = None, + ext: typing.Optional[dict] = None, + ) -> typing.Tuple[int, "Headers", httpcore.SyncByteStream, dict]: + """Add request info to span.""" + if context.get_value("suppress_instrumentation"): + return self._transport.request( + method, url, headers=headers, stream=stream, ext=ext + ) + + span_attributes = _prepare_attributes(method, url) + _headers = _prepare_headers(headers) + span_name = _get_span_name( + span_attributes["http.method"], + span_attributes["http.url"], + name_callback=self._name_callback, + ) + + with _get_tracer( + tracer_provider=self._tracer_provider + ).start_as_current_span( + span_name, kind=SpanKind.CLIENT, attributes=span_attributes + ) as span: + inject(_headers) + + status_code, headers, stream, extensions = self._transport.request( + method, url, headers=_headers.raw, stream=stream, ext=ext + ) + + _apply_status_code(span, status_code) + + if self._span_callback is not None: + self._span_callback( + span, (status_code, headers, stream, extensions) + ) + + return status_code, headers, stream, extensions + + +class AsyncOpenTelemetryTransport(httpcore.AsyncHTTPTransport): + """Async transport class that will trace all requests made with a client. + + Args: + transport: AsyncHTTPTransport instance to wrap + tracer_provider: Tracer provider to use + span_callback: A callback provided with the response info to modify + the span + name_callback: A callback provided with the method and url to process + the span name + """ + + def __init__( + self, + transport: httpcore.AsyncHTTPTransport, + tracer_provider: typing.Optional[TracerProvider] = None, + span_callback: typing.Optional[SpanCallback] = None, + name_callback: typing.Optional[NameCallback] = None, + ): + self._transport = transport + self._tracer_provider = tracer_provider + self._span_callback = span_callback + self._name_callback = name_callback + + async def arequest( + self, + method: bytes, + url: URL, + headers: typing.Optional[Headers] = None, + stream: typing.Optional[httpcore.AsyncByteStream] = None, + ext: typing.Optional[dict] = None, + ) -> typing.Tuple[int, "Headers", httpcore.AsyncByteStream, dict]: + """Add request info to span.""" + if context.get_value("suppress_instrumentation"): + return await self._transport.arequest( + method, url, headers=headers, stream=stream, ext=ext + ) + + span_attributes = _prepare_attributes(method, url) + _headers = _prepare_headers(headers) + span_name = _get_span_name( + span_attributes["http.method"], + span_attributes["http.url"], + name_callback=self._name_callback, + ) + + with _get_tracer( + tracer_provider=self._tracer_provider + ).start_as_current_span( + span_name, kind=SpanKind.CLIENT, attributes=span_attributes + ) as span: + inject(_headers) + + ( + status_code, + headers, + stream, + extensions, + ) = await self._transport.arequest( + method, url, headers=_headers.raw, stream=stream, ext=ext + ) + + _apply_status_code(span, status_code) + + if self._span_callback is not None: + self._span_callback( + span, (status_code, headers, stream, extensions) + ) + + return status_code, headers, stream, extensions + + +def _instrument( + tracer_provider: TracerProvider = None, + span_callback: typing.Optional[SpanCallback] = None, + name_callback: typing.Optional[NameCallback] = None, +) -> None: + """Enables tracing of all Client and AsyncClient instances + + When a Client or AsyncClient gets created, a telemetry transport is passed + in to the instance. + """ + # pylint:disable=unused-argument + def instrumented_sync_init(wrapped, instance, args, kwargs): + if context.get_value("suppress_instrumentation"): + return wrapped(*args, **kwargs) + + transport = kwargs.get("transport") or httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + span_callback=span_callback, + name_callback=name_callback, + ) + + kwargs["transport"] = telemetry_transport + return wrapped(*args, **kwargs) + + def instrumented_async_init(wrapped, instance, args, kwargs): + if context.get_value("suppress_instrumentation"): + return wrapped(*args, **kwargs) + + transport = kwargs.get("transport") or httpx.AsyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + span_callback=span_callback, + name_callback=name_callback, + ) + + kwargs["transport"] = telemetry_transport + return wrapped(*args, **kwargs) + + wrapt.wrap_function_wrapper( + httpx.Client, "__init__", instrumented_sync_init + ) + + wrapt.wrap_function_wrapper( + httpx.AsyncClient, "__init__", instrumented_async_init + ) + + +def _uninstrument() -> None: + """Disables instrumenting for all newly created Client and AsyncClient instances""" + unwrap(httpx.Client, "__init__") + unwrap(httpx.AsyncClient, "__init__") + + +def _uninstrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient] +) -> None: + """Disables instrumentation for the given Client or AsyncClient""" + # pylint: disable=protected-access + telemetry_transport: typing.Union[ + SyncOpenTelemetryTransport, AsyncOpenTelemetryTransport + ] = client._transport + client._transport = telemetry_transport._transport + + +class HTTPXClientInstrumentor(BaseInstrumentor): + """An instrumentor for httpx Client and AsyncClient + + See `BaseInstrumentor` + """ + + def _instrument(self, **kwargs): + """Instruments httpx Client and AsyncClient + + Args: + **kwargs: Optional arguments + ``tracer_provider``: a TracerProvider, defaults to global + ``span_callback``: A callback provided with the response info + to modify the span + ``name_callback``: A callback provided with the method and url + to process the span name + """ + _instrument( + tracer_provider=kwargs.get("tracer_provider"), + span_callback=kwargs.get("span_callback"), + name_callback=kwargs.get("name_callback"), + ) + + def _uninstrument(self, **kwargs): + _uninstrument() + + @staticmethod + def uninstrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient] + ): + """Disables instrumentation for the given client instance""" + _uninstrument_client(client) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py new file mode 100644 index 0000000000..3b16be5d22 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.20.dev0" diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py new file mode 100644 index 0000000000..73d09e2a56 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -0,0 +1,609 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc +import asyncio +import typing +from unittest import mock + +import httpx +import respx + +import opentelemetry.instrumentation.httpx +from opentelemetry import context, trace +from opentelemetry.instrumentation.httpx import ( + AsyncOpenTelemetryTransport, + HTTPXClientInstrumentor, + SyncOpenTelemetryTransport, +) +from opentelemetry.propagate import get_global_textmap, set_global_textmap +from opentelemetry.sdk import resources +from opentelemetry.test.mock_textmap import MockTextMapPropagator +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import StatusCode + +if typing.TYPE_CHECKING: + from opentelemetry.instrumentation.httpx import NameCallback, SpanCallback + from opentelemetry.sdk.trace.export import SpanExporter + from opentelemetry.trace import TracerProvider + + +def async_call(coro: typing.Coroutine) -> asyncio.Task: + loop = asyncio.get_event_loop() + return loop.run_until_complete(coro) + + +# Using this wrapper class to have a base class for the tests while also not +# angering pylint or mypy when calling methods not in the class when only +# subclassing abc.ABC. +class BaseTestCases: + class BaseTest(TestBase, metaclass=abc.ABCMeta): + # pylint: disable=no-member + + URL = "http://httpbin.org/status/200" + + # pylint: disable=invalid-name + def setUp(self): + super().setUp() + respx.start() + respx.get(self.URL).mock(httpx.Response(200, text="Hello!")) + + # pylint: disable=invalid-name + def tearDown(self): + super().tearDown() + respx.stop() + + def assert_span( + self, exporter: "SpanExporter" = None, num_spans: int = 1 + ): + if exporter is None: + exporter = self.memory_exporter + span_list = exporter.get_finished_spans() + self.assertEqual(num_spans, len(span_list)) + if num_spans == 0: + return None + if num_spans == 1: + return span_list[0] + return span_list + + @abc.abstractmethod + def perform_request( + self, + url: str, + method: str = "GET", + headers: typing.Dict[str, str] = None, + client: typing.Union[httpx.Client, httpx.AsyncClient, None] = None, + ): + pass + + def test_basic(self): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "HTTP GET") + + self.assertEqual( + span.attributes, + { + "http.method": "GET", + "http.url": self.URL, + "http.status_code": 200, + }, + ) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.check_span_instrumentation_info( + span, opentelemetry.instrumentation.httpx + ) + + def test_not_foundbasic(self): + url_404 = "http://httpbin.org/status/404" + + with respx.mock: + respx.get(url_404).mock(httpx.Response(404)) + result = self.perform_request(url_404) + + self.assertEqual(result.status_code, 404) + span = self.assert_span() + self.assertEqual(span.attributes.get("http.status_code"), 404) + self.assertIs( + span.status.status_code, trace.StatusCode.ERROR, + ) + + def test_suppress_instrumentation(self): + token = context.attach( + context.set_value("suppress_instrumentation", True) + ) + try: + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + finally: + context.detach(token) + + self.assert_span(num_spans=0) + + def test_distributed_context(self): + previous_propagator = get_global_textmap() + try: + set_global_textmap(MockTextMapPropagator()) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + + headers = dict(respx.calls.last.request.headers) + self.assertIn(MockTextMapPropagator.TRACE_ID_KEY, headers) + self.assertEqual( + str(span.get_span_context().trace_id), + headers[MockTextMapPropagator.TRACE_ID_KEY], + ) + self.assertIn(MockTextMapPropagator.SPAN_ID_KEY, headers) + self.assertEqual( + str(span.get_span_context().span_id), + headers[MockTextMapPropagator.SPAN_ID_KEY], + ) + + finally: + set_global_textmap(previous_propagator) + + def test_requests_500_error(self): + respx.get(self.URL).mock(httpx.Response(500)) + + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + "http.method": "GET", + "http.url": self.URL, + "http.status_code": 500, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_requests_basic_exception(self): + with respx.mock, self.assertRaises(Exception): + respx.get(self.URL).mock(side_effect=Exception) + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_requests_timeout_exception(self): + with respx.mock, self.assertRaises(httpx.TimeoutException): + respx.get(self.URL).mock(side_effect=httpx.TimeoutException) + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_invalid_url(self): + url = "http://[::1/nope" + + with respx.mock, self.assertRaises(httpx.LocalProtocolError): + respx.post("http://nope").pass_through() + self.perform_request(url, method="POST") + + span = self.assert_span() + + self.assertEqual(span.name, "HTTP POST") + self.assertEqual( + span.attributes, + {"http.method": "POST", "http.url": "http://nope"}, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_if_headers_equals_none(self): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + class BaseManualTest(BaseTest, metaclass=abc.ABCMeta): + @abc.abstractmethod + def create_transport( + self, + tracer_provider: typing.Optional["TracerProvider"] = None, + span_callback: typing.Optional["SpanCallback"] = None, + name_callback: typing.Optional["NameCallback"] = None, + ): + pass + + @abc.abstractmethod + def create_client( + self, + transport: typing.Union[ + SyncOpenTelemetryTransport, AsyncOpenTelemetryTransport, None + ] = None, + ): + pass + + def test_default_client(self): + client = self.create_client(transport=None) + result = self.perform_request(self.URL, client=client) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + def test_custom_tracer_provider(self): + resource = resources.Resource.create({}) + result = self.create_tracer_provider(resource=resource) + tracer_provider, exporter = result + + transport = self.create_transport(tracer_provider=tracer_provider) + client = self.create_client(transport) + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span(exporter=exporter) + self.assertIs(span.resource, resource) + + def test_span_callback(self): + def span_callback(span, result: typing.Tuple): + span.set_attribute( + "http.response.body", + b"".join(part for part in result[2]).decode("utf-8"), + ) + + transport = self.create_transport( + tracer_provider=self.tracer_provider, + span_callback=span_callback, + ) + client = self.create_client(transport) + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + self.assertEqual( + span.attributes, + { + "http.method": "GET", + "http.url": self.URL, + "http.status_code": 200, + "http.response.body": "Hello!", + }, + ) + + def test_name_callback(self): + def name_callback(method, url): + return "GET" + url + + transport = self.create_transport(name_callback=name_callback) + client = self.create_client(transport) + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + self.assertEqual(span.name, "GET" + self.URL) + + def test_name_callback_default(self): + def name_callback(method, url): + return 123 + + transport = self.create_transport(name_callback=name_callback) + client = self.create_client(transport) + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + self.assertEqual(span.name, "HTTP GET") + + def test_not_recording(self): + with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: + # original_tracer_provider returns a default tracer provider, which + # in turn will return an INVALID_SPAN, which is always not recording + transport = self.create_transport( + tracer_provider=self.original_tracer_provider + ) + client = self.create_client(transport) + mock_span.is_recording.return_value = False + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + self.assert_span(None, 0) + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + + class BaseInstrumentorTest(BaseTest, metaclass=abc.ABCMeta): + @abc.abstractmethod + def create_client( + self, + transport: typing.Union[ + SyncOpenTelemetryTransport, AsyncOpenTelemetryTransport, None + ] = None, + ): + pass + + def setUp(self): + HTTPXClientInstrumentor().instrument() + super().setUp() + self.client = self.create_client() + + def tearDown(self): + super().tearDown() + HTTPXClientInstrumentor().uninstrument() + + def test_custom_tracer_provider(self): + resource = resources.Resource.create({}) + result = self.create_tracer_provider(resource=resource) + tracer_provider, exporter = result + + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=tracer_provider + ) + client = self.create_client() + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span(exporter=exporter) + self.assertIs(span.resource, resource) + + def test_span_callback(self): + def span_callback(span, result: typing.Tuple): + span.set_attribute( + "http.response.body", + b"".join(part for part in result[2]).decode("utf-8"), + ) + + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + span_callback=span_callback, + ) + client = self.create_client() + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + self.assertEqual( + span.attributes, + { + "http.method": "GET", + "http.url": self.URL, + "http.status_code": 200, + "http.response.body": "Hello!", + }, + ) + + def test_name_callback(self): + def name_callback(method, url): + return "GET" + url + + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + name_callback=name_callback, + ) + client = self.create_client() + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + self.assertEqual(span.name, "GET" + self.URL) + + def test_name_callback_default(self): + def name_callback(method, url): + return 123 + + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + name_callback=name_callback, + ) + client = self.create_client() + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + self.assertEqual(span.name, "HTTP GET") + + def test_not_recording(self): + with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: + # original_tracer_provider returns a default tracer provider, which + # in turn will return an INVALID_SPAN, which is always not recording + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.original_tracer_provider + ) + client = self.create_client() + + mock_span.is_recording.return_value = False + result = self.perform_request(self.URL, client=client) + + self.assertEqual(result.text, "Hello!") + self.assert_span(None, 0) + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + + def test_suppress_instrumentation_new_client(self): + token = context.attach( + context.set_value("suppress_instrumentation", True) + ) + try: + client = self.create_client() + result = self.perform_request(self.URL, client=client) + self.assertEqual(result.text, "Hello!") + finally: + context.detach(token) + + self.assert_span(num_spans=0) + + def test_uninstrument(self): + HTTPXClientInstrumentor().uninstrument() + client = self.create_client() + result = self.perform_request(self.URL, client=client) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + # instrument again to avoid annoying warning message + HTTPXClientInstrumentor().instrument() + + def test_uninstrument_client(self): + client1 = self.create_client() + HTTPXClientInstrumentor().uninstrument_client(client1) + + result = self.perform_request(self.URL, client=client1) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + + # Test that other clients as well as instance client is still + # instrumented + client2 = self.create_client() + result = self.perform_request(self.URL, client=client2) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + self.memory_exporter.clear() + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + +class TestSyncIntegration(BaseTestCases.BaseManualTest): + def setUp(self): + super().setUp() + self.transport = self.create_transport() + self.client = self.create_client(self.transport) + + def tearDown(self): + super().tearDown() + self.client.close() + + def create_transport( + self, + tracer_provider: typing.Optional["TracerProvider"] = None, + span_callback: typing.Optional["SpanCallback"] = None, + name_callback: typing.Optional["NameCallback"] = None, + ): + transport = httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + span_callback=span_callback, + name_callback=name_callback, + ) + return telemetry_transport + + def create_client( + self, transport: typing.Optional[SyncOpenTelemetryTransport] = None, + ): + return httpx.Client(transport=transport) + + def perform_request( + self, + url: str, + method: str = "GET", + headers: typing.Dict[str, str] = None, + client: typing.Union[httpx.Client, httpx.AsyncClient, None] = None, + ): + if client is None: + return self.client.request(method, url, headers=headers) + return client.request(method, url, headers=headers) + + +class TestAsyncIntegration(BaseTestCases.BaseManualTest): + def setUp(self): + super().setUp() + self.transport = self.create_transport() + self.client = self.create_client(self.transport) + + def create_transport( + self, + tracer_provider: typing.Optional["TracerProvider"] = None, + span_callback: typing.Optional["SpanCallback"] = None, + name_callback: typing.Optional["NameCallback"] = None, + ): + transport = httpx.AsyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + span_callback=span_callback, + name_callback=name_callback, + ) + return telemetry_transport + + def create_client( + self, transport: typing.Optional[AsyncOpenTelemetryTransport] = None, + ): + return httpx.AsyncClient(transport=transport) + + def perform_request( + self, + url: str, + method: str = "GET", + headers: typing.Dict[str, str] = None, + client: typing.Union[httpx.Client, httpx.AsyncClient, None] = None, + ): + async def _perform_request(): + nonlocal client + nonlocal method + if client is None: + client = self.client + async with client as _client: + return await _client.request(method, url, headers=headers) + + return async_call(_perform_request()) + + +class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest): + def create_client( + self, transport: typing.Optional[SyncOpenTelemetryTransport] = None, + ): + return httpx.Client() + + def perform_request( + self, + url: str, + method: str = "GET", + headers: typing.Dict[str, str] = None, + client: typing.Union[httpx.Client, httpx.AsyncClient, None] = None, + ): + if client is None: + return self.client.request(method, url, headers=headers) + return client.request(method, url, headers=headers) + + +class TestAsyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest): + def create_client( + self, transport: typing.Optional[AsyncOpenTelemetryTransport] = None, + ): + return httpx.AsyncClient() + + def perform_request( + self, + url: str, + method: str = "GET", + headers: typing.Dict[str, str] = None, + client: typing.Union[httpx.Client, httpx.AsyncClient, None] = None, + ): + async def _perform_request(): + nonlocal client + nonlocal method + if client is None: + client = self.client + async with client as _client: + return await _client.request(method, url, headers=headers) + + return async_call(_perform_request()) diff --git a/tox.ini b/tox.ini index 14f0c656cb..1840d6be49 100644 --- a/tox.ini +++ b/tox.ini @@ -144,6 +144,10 @@ envlist = py3{6,7,8,9}-test-instrumentation-tornado pypy3-test-instrumentation-tornado + ; opentelemetry-instrumentation-httpx + py{6,7,8}-test-instrumentation-httpx + pypy3-test-instrumentation-httpx + ; opentelemetry-util-http py3{6,7,8,9}-test-util-http pypy3-test-util-http @@ -211,6 +215,7 @@ changedir = test-instrumentation-starlette: instrumentation/opentelemetry-instrumentation-starlette/tests test-instrumentation-tornado: instrumentation/opentelemetry-instrumentation-tornado/tests test-instrumentation-wsgi: instrumentation/opentelemetry-instrumentation-wsgi/tests + test-instrumentation-httpx: instrumentation/opentelemetry-instrumentation-httpx/tests test-util-http: util/opentelemetry-util-http/tests test-sdkextension-aws: sdk-extension/opentelemetry-sdk-extension-aws/tests test-propagator-ot-trace: propagator/opentelemetry-propagator-ot-trace/tests @@ -295,6 +300,8 @@ commands_pre = elasticsearch{2,5,6,7}: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-elasticsearch[test] + httpx: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[test] + aws: pip install requests {toxinidir}/sdk-extension/opentelemetry-sdk-extension-aws[test] http: pip install {toxinidir}/util/opentelemetry-util-http[test] @@ -381,6 +388,7 @@ commands_pre = python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-asyncpg[test] python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-tornado[test] python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql[test] + python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[test] python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-datadog[test] python -m pip install -e {toxinidir}/sdk-extension/opentelemetry-sdk-extension-aws[test] python -m pip install -e {toxinidir}/propagator/opentelemetry-propagator-ot-trace[test] From 93c0f2fec351239b42f87fcbd9062dba2cbd4f5a Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Mon, 19 Apr 2021 19:59:52 -0700 Subject: [PATCH 02/20] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 780e1888d5..2607110e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Move `opentelemetry-instrumentation` from core repository ([#465](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/465)) +- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation + ([#461](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/461)) ## [0.20b0](https://github.com/open-telemetry/opentelemetry-python-contrib/releases/tag/v0.20b0) - 2021-04-20 From c00450e7a06307393a02dc996192d77c1de778e3 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Mon, 19 Apr 2021 20:32:47 -0700 Subject: [PATCH 03/20] Update tests --- .../tests/test_httpx_integration.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 73d09e2a56..3ce0d52ee4 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -307,10 +307,8 @@ def name_callback(method, url): def test_not_recording(self): with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: - # original_tracer_provider returns a default tracer provider, which - # in turn will return an INVALID_SPAN, which is always not recording transport = self.create_transport( - tracer_provider=self.original_tracer_provider + tracer_provider=trace._DefaultTracerProvider() ) client = self.create_client(transport) mock_span.is_recording.return_value = False @@ -419,11 +417,9 @@ def name_callback(method, url): def test_not_recording(self): with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: - # original_tracer_provider returns a default tracer provider, which - # in turn will return an INVALID_SPAN, which is always not recording HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( - tracer_provider=self.original_tracer_provider + tracer_provider=trace._DefaultTracerProvider() ) client = self.create_client() From ff314c16b66a0ba6c4c7ae251ef9bcc9913f886a Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 16:38:05 -0700 Subject: [PATCH 04/20] Add instrument_client method on instrumentor --- .../instrumentation/httpx/__init__.py | 41 +++++++++++++++++++ .../tests/test_httpx_integration.py | 10 +++++ 2 files changed, 51 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 72923d65a4..873d7c1784 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -274,6 +274,35 @@ def instrumented_async_init(wrapped, instance, args, kwargs): ) +def _instrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient], + tracer_provider: TracerProvider = None, + span_callback: typing.Optional[SpanCallback] = None, + name_callback: typing.Optional[NameCallback] = None, +) -> None: + """Enables instrumentation for the given Client or AsyncClient""" + # pylint: disable=protected-access + if isinstance(client, httpx.Client): + transport = client._transport or httpcore.SyncHTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + span_callback=span_callback, + name_callback=name_callback, + ) + elif isinstance(client, httpx.AsyncClient): + transport = client._transport or httpcore.aSyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + span_callback=span_callback, + name_callback=name_callback, + ) + else: + raise TypeError("Invalid client provided") + client._transport = telemetry_transport + + def _uninstrument() -> None: """Disables instrumenting for all newly created Client and AsyncClient instances""" unwrap(httpx.Client, "__init__") @@ -317,6 +346,18 @@ def _instrument(self, **kwargs): def _uninstrument(self, **kwargs): _uninstrument() + @staticmethod + def instrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient], + **kwargs + ) -> None: + _instrument_client( + client, + tracer_provider=kwargs.get("tracer_provider"), + span_callback=kwargs.get("span_callback"), + name_callback=kwargs.get("name_callback"), + ) + @staticmethod def uninstrument_client( client: typing.Union[httpx.Client, httpx.AsyncClient] diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 3ce0d52ee4..b8ba227184 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -446,6 +446,16 @@ def test_suppress_instrumentation_new_client(self): self.assert_span(num_spans=0) + def test_instrument_client(self): + HTTPXClientInstrumentor().uninstrument() + client = self.create_client() + HTTPXClientInstrumentor().instrument_client(client) + result = self.perform_request(self.URL, client=client) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=1) + # instrument again to avoid annoying warning message + HTTPXClientInstrumentor().instrument() + def test_uninstrument(self): HTTPXClientInstrumentor().uninstrument() client = self.create_client() From 650bfb786f06cdb22b27e6cb26c8aff5ef94f004 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 16:38:44 -0700 Subject: [PATCH 05/20] Store tracer on instance This is to avoid calling `get_tracer` every time. --- .../instrumentation/httpx/__init__.py | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 873d7c1784..b969c89574 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -39,16 +39,6 @@ Headers = typing.List[typing.Tuple[bytes, bytes]] -def _get_tracer( - *, tracer_provider: typing.Optional[TracerProvider] = None -) -> Tracer: - return get_tracer( - __name__, - instrumenting_library_version=__version__, - tracer_provider=tracer_provider, - ) - - def _get_span_name( method: str, url: str, @@ -105,7 +95,7 @@ def __init__( name_callback: typing.Optional[NameCallback] = None, ): self._transport = transport - self._tracer_provider = tracer_provider + self._tracer = get_tracer(__name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider) self._span_callback = span_callback self._name_callback = name_callback @@ -131,9 +121,7 @@ def request( name_callback=self._name_callback, ) - with _get_tracer( - tracer_provider=self._tracer_provider - ).start_as_current_span( + with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: inject(_headers) @@ -172,7 +160,7 @@ def __init__( name_callback: typing.Optional[NameCallback] = None, ): self._transport = transport - self._tracer_provider = tracer_provider + self._tracer = get_tracer(__name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider) self._span_callback = span_callback self._name_callback = name_callback @@ -198,9 +186,7 @@ async def arequest( name_callback=self._name_callback, ) - with _get_tracer( - tracer_provider=self._tracer_provider - ).start_as_current_span( + with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: inject(_headers) From 8ff4cee7ff577e54a5d3c4445c464fe611305da4 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 17:30:21 -0700 Subject: [PATCH 06/20] Use request and response hooks Replaces the span and name callbacks --- .../instrumentation/httpx/__init__.py | 169 +++++++++++------- .../tests/test_httpx_integration.py | 78 ++++---- 2 files changed, 146 insertions(+), 101 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index b969c89574..212b986edd 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -30,27 +30,26 @@ from opentelemetry.trace.span import Span from opentelemetry.trace.status import Status +URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes] +Headers = typing.List[typing.Tuple[bytes, bytes]] +RequestInfo = typing.Tuple[ + bytes, + URL, + typing.Optional[Headers], + typing.Optional[ + typing.Union[httpcore.SyncByteStream, httpcore.AsyncByteStream] + ], + typing.Optional[dict], +] ResponseInfo = typing.Tuple[ int, typing.List[typing.Tuple[bytes, bytes]], typing.Iterable[bytes], dict, ] -NameCallback = typing.Callable[[str, str], str] -SpanCallback = typing.Callable[[Span, ResponseInfo], None] -URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes] -Headers = typing.List[typing.Tuple[bytes, bytes]] +RequestHook = typing.Callable[[Span, RequestInfo], None] +ResponseHook = typing.Callable[[Span, RequestInfo, ResponseInfo], None] -def _get_span_name( - method: str, - url: str, - *, - name_callback: typing.Optional[NameCallback] = None -) -> str: - span_name = "" - if name_callback is not None: - span_name = name_callback(method, url) - if not span_name or not isinstance(span_name, str): - span_name = "HTTP {}".format(method).strip() - return span_name +def _get_default_span_name(method: str) -> str: + return "HTTP {}".format(method).strip() def _apply_status_code(span: Span, status_code: int) -> None: @@ -81,23 +80,27 @@ class SyncOpenTelemetryTransport(httpcore.SyncHTTPTransport): Args: transport: SyncHTTPTransport instance to wrap tracer_provider: Tracer provider to use - span_callback: A callback provided with the response info to modify - the span - name_callback: A callback provided with the method and url to process - the span name + request_hook: A hook that receives the span and request that is called + right after the span is created + response_hook: A hook that receives the span, request, and response + that is called right before the span ends """ def __init__( self, transport: httpcore.SyncHTTPTransport, tracer_provider: typing.Optional[TracerProvider] = None, - span_callback: typing.Optional[SpanCallback] = None, - name_callback: typing.Optional[NameCallback] = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = None, ): self._transport = transport - self._tracer = get_tracer(__name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider) - self._span_callback = span_callback - self._name_callback = name_callback + self._tracer = get_tracer( + __name__, + instrumenting_library_version=__version__, + tracer_provider=tracer_provider, + ) + self._request_hook = request_hook + self._response_hook = response_hook def request( self, @@ -115,15 +118,21 @@ def request( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) - span_name = _get_span_name( + span_name = _get_default_span_name(span_attributes["http.method"]) + request = ( span_attributes["http.method"], span_attributes["http.url"], - name_callback=self._name_callback, + headers, + stream, + ext, ) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: + if self._request_hook is not None: + self._request_hook(span, request) + inject(_headers) status_code, headers, stream, extensions = self._transport.request( @@ -132,9 +141,9 @@ def request( _apply_status_code(span, status_code) - if self._span_callback is not None: - self._span_callback( - span, (status_code, headers, stream, extensions) + if self._response_hook is not None: + self._response_hook( + span, request, (status_code, headers, stream, extensions) ) return status_code, headers, stream, extensions @@ -146,23 +155,27 @@ class AsyncOpenTelemetryTransport(httpcore.AsyncHTTPTransport): Args: transport: AsyncHTTPTransport instance to wrap tracer_provider: Tracer provider to use - span_callback: A callback provided with the response info to modify - the span - name_callback: A callback provided with the method and url to process - the span name + request_hook: A hook that receives the span and request that is called + right after the span is created + response_hook: A hook that receives the span, request, and response + that is called right before the span ends """ def __init__( self, transport: httpcore.AsyncHTTPTransport, tracer_provider: typing.Optional[TracerProvider] = None, - span_callback: typing.Optional[SpanCallback] = None, - name_callback: typing.Optional[NameCallback] = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = None, ): self._transport = transport - self._tracer = get_tracer(__name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider) - self._span_callback = span_callback - self._name_callback = name_callback + self._tracer = get_tracer( + __name__, + instrumenting_library_version=__version__, + tracer_provider=tracer_provider, + ) + self._request_hook = request_hook + self._response_hook = response_hook async def arequest( self, @@ -180,15 +193,21 @@ async def arequest( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) - span_name = _get_span_name( + span_name = _get_default_span_name(span_attributes["http.method"]) + request = ( span_attributes["http.method"], span_attributes["http.url"], - name_callback=self._name_callback, + headers, + stream, + ext, ) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: + if self._request_hook is not None: + self._request_hook(span, request) + inject(_headers) ( @@ -202,9 +221,9 @@ async def arequest( _apply_status_code(span, status_code) - if self._span_callback is not None: - self._span_callback( - span, (status_code, headers, stream, extensions) + if self._response_hook is not None: + self._response_hook( + span, request, (status_code, headers, stream, extensions) ) return status_code, headers, stream, extensions @@ -212,8 +231,8 @@ async def arequest( def _instrument( tracer_provider: TracerProvider = None, - span_callback: typing.Optional[SpanCallback] = None, - name_callback: typing.Optional[NameCallback] = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = None, ) -> None: """Enables tracing of all Client and AsyncClient instances @@ -229,8 +248,8 @@ def instrumented_sync_init(wrapped, instance, args, kwargs): telemetry_transport = SyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, - span_callback=span_callback, - name_callback=name_callback, + request_hook=request_hook, + response_hook=response_hook, ) kwargs["transport"] = telemetry_transport @@ -244,8 +263,8 @@ def instrumented_async_init(wrapped, instance, args, kwargs): telemetry_transport = AsyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, - span_callback=span_callback, - name_callback=name_callback, + request_hook=request_hook, + response_hook=response_hook, ) kwargs["transport"] = telemetry_transport @@ -263,8 +282,8 @@ def instrumented_async_init(wrapped, instance, args, kwargs): def _instrument_client( client: typing.Union[httpx.Client, httpx.AsyncClient], tracer_provider: TracerProvider = None, - span_callback: typing.Optional[SpanCallback] = None, - name_callback: typing.Optional[NameCallback] = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = None, ) -> None: """Enables instrumentation for the given Client or AsyncClient""" # pylint: disable=protected-access @@ -273,16 +292,16 @@ def _instrument_client( telemetry_transport = SyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, - span_callback=span_callback, - name_callback=name_callback, + request_hook=request_hook, + response_hook=response_hook, ) elif isinstance(client, httpx.AsyncClient): - transport = client._transport or httpcore.aSyncHTTPTransport() + transport = client._transport or httpcore.AsyncHTTPTransport() telemetry_transport = AsyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, - span_callback=span_callback, - name_callback=name_callback, + request_hook=request_hook, + response_hook=response_hook, ) else: raise TypeError("Invalid client provided") @@ -318,15 +337,15 @@ def _instrument(self, **kwargs): Args: **kwargs: Optional arguments ``tracer_provider``: a TracerProvider, defaults to global - ``span_callback``: A callback provided with the response info - to modify the span - ``name_callback``: A callback provided with the method and url - to process the span name + ``request_hook``: A hook that receives the span and request that is called + right after the span is created + ``response_hook``: A hook that receives the span, request, and response + that is called right before the span ends """ _instrument( tracer_provider=kwargs.get("tracer_provider"), - span_callback=kwargs.get("span_callback"), - name_callback=kwargs.get("name_callback"), + request_hook=kwargs.get("request_hook"), + response_hook=kwargs.get("response_hook"), ) def _uninstrument(self, **kwargs): @@ -334,19 +353,33 @@ def _uninstrument(self, **kwargs): @staticmethod def instrument_client( - client: typing.Union[httpx.Client, httpx.AsyncClient], - **kwargs + client: typing.Union[httpx.Client, httpx.AsyncClient], **kwargs ) -> None: + """Instruments httpx Client and AsyncClient + + Args: + client: The httpx Client or AsyncClient instance + **kwargs: Optional arguments + ``tracer_provider``: a TracerProvider, defaults to global + ``request_hook``: A hook that receives the span and request that is called + right after the span is created + ``response_hook``: A hook that receives the span, request, and response + that is called right before the span ends + """ _instrument_client( client, tracer_provider=kwargs.get("tracer_provider"), - span_callback=kwargs.get("span_callback"), - name_callback=kwargs.get("name_callback"), + request_hook=kwargs.get("request_hook"), + response_hook=kwargs.get("response_hook"), ) @staticmethod def uninstrument_client( client: typing.Union[httpx.Client, httpx.AsyncClient] ): - """Disables instrumentation for the given client instance""" + """Disables instrumentation for the given client instance + + Args: + client: The httpx Client or AsyncClient instance + """ _uninstrument_client(client) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index b8ba227184..bedfb48008 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -34,9 +34,15 @@ from opentelemetry.trace import StatusCode if typing.TYPE_CHECKING: - from opentelemetry.instrumentation.httpx import NameCallback, SpanCallback + from opentelemetry.instrumentation.httpx import ( + RequestHook, + RequestInfo, + ResponseHook, + ResponseInfo, + ) from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import TracerProvider + from opentelemetry.trace.span import Span def async_call(coro: typing.Coroutine) -> asyncio.Task: @@ -218,8 +224,8 @@ class BaseManualTest(BaseTest, metaclass=abc.ABCMeta): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, - span_callback: typing.Optional["SpanCallback"] = None, - name_callback: typing.Optional["NameCallback"] = None, + request_hook: typing.Optional["RequestHook"] = None, + response_hook: typing.Optional["ResponseHook"] = None, ): pass @@ -255,16 +261,18 @@ def test_custom_tracer_provider(self): span = self.assert_span(exporter=exporter) self.assertIs(span.resource, resource) - def test_span_callback(self): - def span_callback(span, result: typing.Tuple): + def test_response_hook(self): + def response_hook( + span: "Span", request: "RequestInfo", response: "ResponseInfo" + ): span.set_attribute( "http.response.body", - b"".join(part for part in result[2]).decode("utf-8"), + b"".join(part for part in response[2]).decode("utf-8"), ) transport = self.create_transport( tracer_provider=self.tracer_provider, - span_callback=span_callback, + response_hook=response_hook, ) client = self.create_client(transport) result = self.perform_request(self.URL, client=client) @@ -281,11 +289,12 @@ def span_callback(span, result: typing.Tuple): }, ) - def test_name_callback(self): - def name_callback(method, url): - return "GET" + url + def test_request_hook(self): + def request_hook(span: "Span", request: "RequestInfo"): + url = request[1] + span.update_name("GET" + url) - transport = self.create_transport(name_callback=name_callback) + transport = self.create_transport(request_hook=request_hook) client = self.create_client(transport) result = self.perform_request(self.URL, client=client) @@ -293,11 +302,11 @@ def name_callback(method, url): span = self.assert_span() self.assertEqual(span.name, "GET" + self.URL) - def test_name_callback_default(self): - def name_callback(method, url): + def test_request_hook_no_span_change(self): + def request_hook(span: "Span", request: "RequestInfo"): return 123 - transport = self.create_transport(name_callback=name_callback) + transport = self.create_transport(request_hook=request_hook) client = self.create_client(transport) result = self.perform_request(self.URL, client=client) @@ -356,17 +365,19 @@ def test_custom_tracer_provider(self): span = self.assert_span(exporter=exporter) self.assertIs(span.resource, resource) - def test_span_callback(self): - def span_callback(span, result: typing.Tuple): + def test_response_hook(self): + def response_hook( + span, request: "RequestInfo", response: "ResponseInfo" + ): span.set_attribute( "http.response.body", - b"".join(part for part in result[2]).decode("utf-8"), + b"".join(part for part in response[2]).decode("utf-8"), ) HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( tracer_provider=self.tracer_provider, - span_callback=span_callback, + response_hook=response_hook, ) client = self.create_client() result = self.perform_request(self.URL, client=client) @@ -383,14 +394,15 @@ def span_callback(span, result: typing.Tuple): }, ) - def test_name_callback(self): - def name_callback(method, url): - return "GET" + url + def test_request_hook(self): + def request_hook(span: "Span", request: "RequestInfo"): + url = request[1] + span.update_name("GET" + url) HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( tracer_provider=self.tracer_provider, - name_callback=name_callback, + request_hook=request_hook, ) client = self.create_client() result = self.perform_request(self.URL, client=client) @@ -399,14 +411,14 @@ def name_callback(method, url): span = self.assert_span() self.assertEqual(span.name, "GET" + self.URL) - def test_name_callback_default(self): - def name_callback(method, url): + def test_request_hook_no_span_update(self): + def request_hook(span: "Span", request: "RequestInfo"): return 123 HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( tracer_provider=self.tracer_provider, - name_callback=name_callback, + request_hook=request_hook, ) client = self.create_client() result = self.perform_request(self.URL, client=client) @@ -500,15 +512,15 @@ def tearDown(self): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, - span_callback: typing.Optional["SpanCallback"] = None, - name_callback: typing.Optional["NameCallback"] = None, + request_hook: typing.Optional["RequestHook"] = None, + response_hook: typing.Optional["ResponseHook"] = None, ): transport = httpx.HTTPTransport() telemetry_transport = SyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, - span_callback=span_callback, - name_callback=name_callback, + request_hook=request_hook, + response_hook=response_hook, ) return telemetry_transport @@ -538,15 +550,15 @@ def setUp(self): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, - span_callback: typing.Optional["SpanCallback"] = None, - name_callback: typing.Optional["NameCallback"] = None, + request_hook: typing.Optional["RequestHook"] = None, + response_hook: typing.Optional["ResponseHook"] = None, ): transport = httpx.AsyncHTTPTransport() telemetry_transport = AsyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, - span_callback=span_callback, - name_callback=name_callback, + request_hook=request_hook, + response_hook=response_hook, ) return telemetry_transport From 0eac3d04bc3915c4b090dd02808e71040ed8275c Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 18:04:39 -0700 Subject: [PATCH 07/20] Use httpx Request and Response classes Simplifies the usage of request/response hooks for consumers with expected objects --- .../instrumentation/httpx/__init__.py | 50 +++++++------------ .../tests/test_httpx_integration.py | 24 ++++----- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 212b986edd..b52aed3431 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -32,20 +32,8 @@ URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes] Headers = typing.List[typing.Tuple[bytes, bytes]] -RequestInfo = typing.Tuple[ - bytes, - URL, - typing.Optional[Headers], - typing.Optional[ - typing.Union[httpcore.SyncByteStream, httpcore.AsyncByteStream] - ], - typing.Optional[dict], -] -ResponseInfo = typing.Tuple[ - int, typing.List[typing.Tuple[bytes, bytes]], typing.Iterable[bytes], dict, -] -RequestHook = typing.Callable[[Span, RequestInfo], None] -ResponseHook = typing.Callable[[Span, RequestInfo, ResponseInfo], None] +RequestHook = typing.Callable[[Span, httpx.Request], None] +ResponseHook = typing.Callable[[Span, httpx.Request, httpx.Response], None] def _get_default_span_name(method: str) -> str: @@ -119,12 +107,11 @@ def request( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) span_name = _get_default_span_name(span_attributes["http.method"]) - request = ( - span_attributes["http.method"], - span_attributes["http.url"], - headers, - stream, - ext, + request = httpx.Request( + method, + url, + headers=headers, + stream=stream, ) with self._tracer.start_as_current_span( @@ -142,9 +129,9 @@ def request( _apply_status_code(span, status_code) if self._response_hook is not None: - self._response_hook( - span, request, (status_code, headers, stream, extensions) - ) + response = httpx.Response(status_code=status_code, headers=headers, stream=stream, ext=extensions, request=request) + response.read() + self._response_hook(span, request, response) return status_code, headers, stream, extensions @@ -194,12 +181,11 @@ async def arequest( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) span_name = _get_default_span_name(span_attributes["http.method"]) - request = ( - span_attributes["http.method"], - span_attributes["http.url"], - headers, - stream, - ext, + request = httpx.Request( + method, + url, + headers=headers, + stream=stream, ) with self._tracer.start_as_current_span( @@ -222,9 +208,9 @@ async def arequest( _apply_status_code(span, status_code) if self._response_hook is not None: - self._response_hook( - span, request, (status_code, headers, stream, extensions) - ) + response = httpx.Response(status_code=status_code, headers=headers, stream=stream, ext=extensions, request=request) + response.read() + self._response_hook(span, request, response) return status_code, headers, stream, extensions diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index bedfb48008..572e0e48b9 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -36,9 +36,7 @@ if typing.TYPE_CHECKING: from opentelemetry.instrumentation.httpx import ( RequestHook, - RequestInfo, ResponseHook, - ResponseInfo, ) from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import TracerProvider @@ -263,11 +261,11 @@ def test_custom_tracer_provider(self): def test_response_hook(self): def response_hook( - span: "Span", request: "RequestInfo", response: "ResponseInfo" + span: "Span", request: httpx.Request, response: httpx.Response ): span.set_attribute( "http.response.body", - b"".join(part for part in response[2]).decode("utf-8"), + response.content.decode("utf-8") ) transport = self.create_transport( @@ -290,9 +288,8 @@ def response_hook( ) def test_request_hook(self): - def request_hook(span: "Span", request: "RequestInfo"): - url = request[1] - span.update_name("GET" + url) + def request_hook(span: "Span", request: httpx.Request): + span.update_name("GET" + str(request.url)) transport = self.create_transport(request_hook=request_hook) client = self.create_client(transport) @@ -303,7 +300,7 @@ def request_hook(span: "Span", request: "RequestInfo"): self.assertEqual(span.name, "GET" + self.URL) def test_request_hook_no_span_change(self): - def request_hook(span: "Span", request: "RequestInfo"): + def request_hook(span: "Span", request: httpx.Request): return 123 transport = self.create_transport(request_hook=request_hook) @@ -367,11 +364,11 @@ def test_custom_tracer_provider(self): def test_response_hook(self): def response_hook( - span, request: "RequestInfo", response: "ResponseInfo" + span, request: httpx.Request, response: httpx.Response ): span.set_attribute( "http.response.body", - b"".join(part for part in response[2]).decode("utf-8"), + response.content.decode("utf-8") ) HTTPXClientInstrumentor().uninstrument() @@ -395,9 +392,8 @@ def response_hook( ) def test_request_hook(self): - def request_hook(span: "Span", request: "RequestInfo"): - url = request[1] - span.update_name("GET" + url) + def request_hook(span: "Span", request: httpx.Request): + span.update_name("GET" + str(request.url)) HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( @@ -412,7 +408,7 @@ def request_hook(span: "Span", request: "RequestInfo"): self.assertEqual(span.name, "GET" + self.URL) def test_request_hook_no_span_update(self): - def request_hook(span: "Span", request: "RequestInfo"): + def request_hook(span: "Span", request: httpx.Request): return 123 HTTPXClientInstrumentor().uninstrument() From 7d18be7a6f6736581e8a4ace237c97cba780a17a Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 18:29:14 -0700 Subject: [PATCH 08/20] Use SpanAttributes from semantic conventions --- .../setup.cfg | 1 + .../instrumentation/httpx/__init__.py | 11 ++--- .../tests/test_httpx_integration.py | 40 ++++++++++--------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg index e0d54f14b6..e9ff93ddd3 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -40,6 +40,7 @@ packages=find_namespace: install_requires = opentelemetry-api == 1.0.1.dev0 opentelemetry-instrumentation == 0.20.dev0 + opentelemetry-semantic-conventions == 0.20.dev0 httpx >= 0.17.0, < 0.18.0 wrapt >= 1.0.0, < 2.0.0 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index b52aed3431..5ca13480c1 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -26,6 +26,7 @@ unwrap, ) from opentelemetry.propagate import inject +from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer from opentelemetry.trace.span import Span from opentelemetry.trace.status import Status @@ -44,7 +45,7 @@ def _apply_status_code(span: Span, status_code: int) -> None: if not span.is_recording(): return - span.set_attribute("http.status_code", status_code) + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) span.set_status(Status(http_status_to_status_code(status_code))) @@ -52,8 +53,8 @@ def _prepare_attributes(method: bytes, url: URL) -> typing.Dict[str, str]: _method = method.decode().upper() _url = str(httpx.URL(url)) span_attributes = { - "http.method": _method, - "http.url": _url, + SpanAttributes.HTTP_METHOD: _method, + SpanAttributes.HTTP_URL: _url, } return span_attributes @@ -106,7 +107,7 @@ def request( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) - span_name = _get_default_span_name(span_attributes["http.method"]) + span_name = _get_default_span_name(span_attributes[SpanAttributes.HTTP_METHOD]) request = httpx.Request( method, url, @@ -180,7 +181,7 @@ async def arequest( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) - span_name = _get_default_span_name(span_attributes["http.method"]) + span_name = _get_default_span_name(span_attributes[SpanAttributes.HTTP_METHOD]) request = httpx.Request( method, url, diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 572e0e48b9..397124a2e2 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -29,6 +29,7 @@ ) from opentelemetry.propagate import get_global_textmap, set_global_textmap from opentelemetry.sdk import resources +from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.test.mock_textmap import MockTextMapPropagator from opentelemetry.test.test_base import TestBase from opentelemetry.trace import StatusCode @@ -43,6 +44,9 @@ from opentelemetry.trace.span import Span +HTTP_RESPONSE_BODY = "http.response.body" + + def async_call(coro: typing.Coroutine) -> asyncio.Task: loop = asyncio.get_event_loop() return loop.run_until_complete(coro) @@ -102,9 +106,9 @@ def test_basic(self): self.assertEqual( span.attributes, { - "http.method": "GET", - "http.url": self.URL, - "http.status_code": 200, + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.HTTP_STATUS_CODE: 200, }, ) @@ -123,7 +127,7 @@ def test_not_foundbasic(self): self.assertEqual(result.status_code, 404) span = self.assert_span() - self.assertEqual(span.attributes.get("http.status_code"), 404) + self.assertEqual(span.attributes.get(SpanAttributes.HTTP_STATUS_CODE), 404) self.assertIs( span.status.status_code, trace.StatusCode.ERROR, ) @@ -173,9 +177,9 @@ def test_requests_500_error(self): self.assertEqual( span.attributes, { - "http.method": "GET", - "http.url": self.URL, - "http.status_code": 500, + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.HTTP_STATUS_CODE: 500, }, ) self.assertEqual(span.status.status_code, StatusCode.ERROR) @@ -208,7 +212,7 @@ def test_invalid_url(self): self.assertEqual(span.name, "HTTP POST") self.assertEqual( span.attributes, - {"http.method": "POST", "http.url": "http://nope"}, + {SpanAttributes.HTTP_METHOD: "POST", SpanAttributes.HTTP_URL: "http://nope"}, ) self.assertEqual(span.status.status_code, StatusCode.ERROR) @@ -264,7 +268,7 @@ def response_hook( span: "Span", request: httpx.Request, response: httpx.Response ): span.set_attribute( - "http.response.body", + HTTP_RESPONSE_BODY, response.content.decode("utf-8") ) @@ -280,10 +284,10 @@ def response_hook( self.assertEqual( span.attributes, { - "http.method": "GET", - "http.url": self.URL, - "http.status_code": 200, - "http.response.body": "Hello!", + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_BODY: "Hello!", }, ) @@ -367,7 +371,7 @@ def response_hook( span, request: httpx.Request, response: httpx.Response ): span.set_attribute( - "http.response.body", + HTTP_RESPONSE_BODY, response.content.decode("utf-8") ) @@ -384,10 +388,10 @@ def response_hook( self.assertEqual( span.attributes, { - "http.method": "GET", - "http.url": self.URL, - "http.status_code": 200, - "http.response.body": "Hello!", + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_BODY: "Hello!", }, ) From 603ad00d9b71e173e024c7acc66ef04b79f65435 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 20:46:17 -0700 Subject: [PATCH 09/20] Update documentation --- docs/conf.py | 1 + docs/nitpick-exceptions.ini | 9 ++-- .../README.rst | 52 +++++++++++++++++-- .../instrumentation/httpx/__init__.py | 24 +++++---- tox.ini | 2 +- 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0c72e82b80..b7cf865032 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,6 +97,7 @@ "https://opentelemetry-python.readthedocs.io/en/latest/", None, ), + "https": ("https://www.encode.io/httpcore/", None), } # http://www.sphinx-doc.org/en/master/config.html#confval-nitpicky diff --git a/docs/nitpick-exceptions.ini b/docs/nitpick-exceptions.ini index bb636af132..d8aab0ec87 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -18,10 +18,11 @@ class_references= Setter Getter ; - AwsXRayFormat.extract - httpcore.SyncHTTPTransport - httpcore.AsyncHTTPTransport - httpcore.SyncByteStream - httpcore.AsyncByteStream + ; httpx changes __module__ causing Sphinx to error and no Sphinx site is available + httpx.Request + httpx.Response + httpx.Client + httpx.AsyncClient anys= ; API diff --git a/instrumentation/opentelemetry-instrumentation-httpx/README.rst b/instrumentation/opentelemetry-instrumentation-httpx/README.rst index 95c4b26bf9..0de71f828a 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/README.rst +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -40,8 +40,31 @@ requests. async with httpx.AsyncClient() as client: response = await client.get(url) +Instrumenting single clients +**************************** + +If you only want to instrument requests for specific client instances, you can +use the `instrument_client` method. + + +.. code-block:: python + + import httpx + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + url = "https://httpbin.org/get" + + with httpx.Client(transport=telemetry_transport) as client: + HTTPXClientInstrumentor.instrument_client(client) + response = client.get(url) + + async with httpx.AsyncClient(transport=telemetry_transport) as client: + HTTPXClientInstrumentor.instrument_client(client) + response = await client.get(url) + + Uninstrument -^^^^^^^^^^^^ +************ If you need to uninstrument clients, there are two options available. @@ -60,11 +83,10 @@ If you need to uninstrument clients, there are two options available. HTTPXClientInstrumentor().uninstrument() -Instrumenting single clients -**************************** +Using transports directly +************************* -If you only want to instrument requests for specific client instances, you can -create the transport instance manually and pass it in when creating the client. +If you don't want to use the instrumentor class, you can use the transport classes directly. .. code-block:: python @@ -89,6 +111,26 @@ create the transport instance manually and pass it in when creating the client. response = await client.get(url) +Request and response hooks +*************************** + +The instrumentation supports specifying request and response hooks. These are functions that get called back by the instrumentation right after a Span is created for a request +and right before the span is finished while processing a response. The hooks can be configured as follows: + + +.. code-block:: python + + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + def request_hook(span, request): + pass + + def response_hook(span, request, response): + pass + + HTTPXClientInstrumentor().instrument(request_hook=request_hook, response_hook=response_hook) + + References ---------- diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 5ca13480c1..287f2bd1ef 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -340,24 +340,26 @@ def _uninstrument(self, **kwargs): @staticmethod def instrument_client( - client: typing.Union[httpx.Client, httpx.AsyncClient], **kwargs + client: typing.Union[httpx.Client, httpx.AsyncClient], + tracer_provider: TracerProvider = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = None, ) -> None: - """Instruments httpx Client and AsyncClient + """Instrument httpx Client or AsyncClient Args: client: The httpx Client or AsyncClient instance - **kwargs: Optional arguments - ``tracer_provider``: a TracerProvider, defaults to global - ``request_hook``: A hook that receives the span and request that is called - right after the span is created - ``response_hook``: A hook that receives the span, request, and response - that is called right before the span ends + tracer_provider: A TracerProvider, defaults to global + request_hook: A hook that receives the span and request that is called + right after the span is created + response_hook: A hook that receives the span, request, and response + that is called right before the span ends """ _instrument_client( client, - tracer_provider=kwargs.get("tracer_provider"), - request_hook=kwargs.get("request_hook"), - response_hook=kwargs.get("response_hook"), + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, ) @staticmethod diff --git a/tox.ini b/tox.ini index 1840d6be49..7db64adcb8 100644 --- a/tox.ini +++ b/tox.ini @@ -300,7 +300,7 @@ commands_pre = elasticsearch{2,5,6,7}: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-elasticsearch[test] - httpx: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[test] + httpx: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[test] aws: pip install requests {toxinidir}/sdk-extension/opentelemetry-sdk-extension-aws[test] From 3d83fb5fbab93ea1b519d053822dec3e31ef4d3b Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 20:52:32 -0700 Subject: [PATCH 10/20] Wrap client `send` instead of `__init__` Allows for instrumenting and uninstrumenting existing client instances --- .../README.rst | 5 ++-- .../instrumentation/httpx/__init__.py | 29 +++++++++---------- .../tests/test_httpx_integration.py | 11 +++++-- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/README.rst b/instrumentation/opentelemetry-instrumentation-httpx/README.rst index 0de71f828a..6794625aa4 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/README.rst +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -23,8 +23,7 @@ Usage Instrumenting all clients ************************* -When using the instrumentor, all newly created clients will automatically trace -requests. +When using the instrumentor, all clients will automatically trace requests. .. code-block:: python @@ -79,7 +78,7 @@ If you need to uninstrument clients, there are two options available. # Uninstrument a specific client HTTPXClientInstrumentor.uninstrument_client(client) - # Uninstrument all new clients + # Uninstrument all clients HTTPXClientInstrumentor().uninstrument() diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 287f2bd1ef..520a810381 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -227,11 +227,11 @@ def _instrument( in to the instance. """ # pylint:disable=unused-argument - def instrumented_sync_init(wrapped, instance, args, kwargs): + def instrumented_sync_send(wrapped, instance, args, kwargs): if context.get_value("suppress_instrumentation"): return wrapped(*args, **kwargs) - transport = kwargs.get("transport") or httpx.HTTPTransport() + transport = instance._transport or httpx.HTTPTransport() telemetry_transport = SyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, @@ -239,14 +239,14 @@ def instrumented_sync_init(wrapped, instance, args, kwargs): response_hook=response_hook, ) - kwargs["transport"] = telemetry_transport + instance._transport = telemetry_transport return wrapped(*args, **kwargs) - def instrumented_async_init(wrapped, instance, args, kwargs): + async def instrumented_async_send(wrapped, instance, args, kwargs): if context.get_value("suppress_instrumentation"): - return wrapped(*args, **kwargs) + return await wrapped(*args, **kwargs) - transport = kwargs.get("transport") or httpx.AsyncHTTPTransport() + transport = instance._transport or httpx.AsyncHTTPTransport() telemetry_transport = AsyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, @@ -254,15 +254,15 @@ def instrumented_async_init(wrapped, instance, args, kwargs): response_hook=response_hook, ) - kwargs["transport"] = telemetry_transport - return wrapped(*args, **kwargs) + instance._transport = telemetry_transport + return await wrapped(*args, **kwargs) wrapt.wrap_function_wrapper( - httpx.Client, "__init__", instrumented_sync_init + httpx.Client, "send", instrumented_sync_send ) wrapt.wrap_function_wrapper( - httpx.AsyncClient, "__init__", instrumented_async_init + httpx.AsyncClient, "send", instrumented_async_send ) @@ -297,8 +297,8 @@ def _instrument_client( def _uninstrument() -> None: """Disables instrumenting for all newly created Client and AsyncClient instances""" - unwrap(httpx.Client, "__init__") - unwrap(httpx.AsyncClient, "__init__") + unwrap(httpx.Client, "send") + unwrap(httpx.AsyncClient, "send") def _uninstrument_client( @@ -306,10 +306,7 @@ def _uninstrument_client( ) -> None: """Disables instrumentation for the given Client or AsyncClient""" # pylint: disable=protected-access - telemetry_transport: typing.Union[ - SyncOpenTelemetryTransport, AsyncOpenTelemetryTransport - ] = client._transport - client._transport = telemetry_transport._transport + unwrap(client, "send") class HTTPXClientInstrumentor(BaseInstrumentor): diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 397124a2e2..548447b083 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -458,6 +458,14 @@ def test_suppress_instrumentation_new_client(self): self.assert_span(num_spans=0) + def test_existing_client(self): + HTTPXClientInstrumentor().uninstrument() + client = self.create_client() + HTTPXClientInstrumentor().instrument() + result = self.perform_request(self.URL, client=client) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=1) + def test_instrument_client(self): HTTPXClientInstrumentor().uninstrument() client = self.create_client() @@ -470,8 +478,7 @@ def test_instrument_client(self): def test_uninstrument(self): HTTPXClientInstrumentor().uninstrument() - client = self.create_client() - result = self.perform_request(self.URL, client=client) + result = self.perform_request(self.URL) self.assertEqual(result.text, "Hello!") self.assert_span(num_spans=0) # instrument again to avoid annoying warning message From 3e51f1c7dd1e257a2c823fa741240243307270ab Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 21:09:26 -0700 Subject: [PATCH 11/20] Fix lint issues --- .../instrumentation/httpx/__init__.py | 38 ++++++++++--------- .../tests/test_httpx_integration.py | 20 +++++----- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 520a810381..5525bed9aa 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -107,13 +107,10 @@ def request( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) - span_name = _get_default_span_name(span_attributes[SpanAttributes.HTTP_METHOD]) - request = httpx.Request( - method, - url, - headers=headers, - stream=stream, + span_name = _get_default_span_name( + span_attributes[SpanAttributes.HTTP_METHOD] ) + request = httpx.Request(method, url, headers=headers, stream=stream) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes @@ -130,7 +127,13 @@ def request( _apply_status_code(span, status_code) if self._response_hook is not None: - response = httpx.Response(status_code=status_code, headers=headers, stream=stream, ext=extensions, request=request) + response = httpx.Response( + status_code=status_code, + headers=headers, + stream=stream, + ext=extensions, + request=request, + ) response.read() self._response_hook(span, request, response) @@ -181,13 +184,10 @@ async def arequest( span_attributes = _prepare_attributes(method, url) _headers = _prepare_headers(headers) - span_name = _get_default_span_name(span_attributes[SpanAttributes.HTTP_METHOD]) - request = httpx.Request( - method, - url, - headers=headers, - stream=stream, + span_name = _get_default_span_name( + span_attributes[SpanAttributes.HTTP_METHOD] ) + request = httpx.Request(method, url, headers=headers, stream=stream) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes @@ -209,7 +209,13 @@ async def arequest( _apply_status_code(span, status_code) if self._response_hook is not None: - response = httpx.Response(status_code=status_code, headers=headers, stream=stream, ext=extensions, request=request) + response = httpx.Response( + status_code=status_code, + headers=headers, + stream=stream, + ext=extensions, + request=request, + ) response.read() self._response_hook(span, request, response) @@ -257,9 +263,7 @@ async def instrumented_async_send(wrapped, instance, args, kwargs): instance._transport = telemetry_transport return await wrapped(*args, **kwargs) - wrapt.wrap_function_wrapper( - httpx.Client, "send", instrumented_sync_send - ) + wrapt.wrap_function_wrapper(httpx.Client, "send", instrumented_sync_send) wrapt.wrap_function_wrapper( httpx.AsyncClient, "send", instrumented_async_send diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 548447b083..d35680b205 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -35,10 +35,7 @@ from opentelemetry.trace import StatusCode if typing.TYPE_CHECKING: - from opentelemetry.instrumentation.httpx import ( - RequestHook, - ResponseHook, - ) + from opentelemetry.instrumentation.httpx import RequestHook, ResponseHook from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import TracerProvider from opentelemetry.trace.span import Span @@ -127,7 +124,9 @@ def test_not_foundbasic(self): self.assertEqual(result.status_code, 404) span = self.assert_span() - self.assertEqual(span.attributes.get(SpanAttributes.HTTP_STATUS_CODE), 404) + self.assertEqual( + span.attributes.get(SpanAttributes.HTTP_STATUS_CODE), 404 + ) self.assertIs( span.status.status_code, trace.StatusCode.ERROR, ) @@ -212,7 +211,10 @@ def test_invalid_url(self): self.assertEqual(span.name, "HTTP POST") self.assertEqual( span.attributes, - {SpanAttributes.HTTP_METHOD: "POST", SpanAttributes.HTTP_URL: "http://nope"}, + { + SpanAttributes.HTTP_METHOD: "POST", + SpanAttributes.HTTP_URL: "http://nope", + }, ) self.assertEqual(span.status.status_code, StatusCode.ERROR) @@ -268,8 +270,7 @@ def response_hook( span: "Span", request: httpx.Request, response: httpx.Response ): span.set_attribute( - HTTP_RESPONSE_BODY, - response.content.decode("utf-8") + HTTP_RESPONSE_BODY, response.content.decode("utf-8") ) transport = self.create_transport( @@ -371,8 +372,7 @@ def response_hook( span, request: httpx.Request, response: httpx.Response ): span.set_attribute( - HTTP_RESPONSE_BODY, - response.content.decode("utf-8") + HTTP_RESPONSE_BODY, response.content.decode("utf-8") ) HTTPXClientInstrumentor().uninstrument() From 19fc979bb51391073cab02147891326686f0f622 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 20 Apr 2021 21:22:08 -0700 Subject: [PATCH 12/20] Fix typo in httpcore intersphinx Not sure how that happened. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index b7cf865032..b6654ce83b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,7 +97,7 @@ "https://opentelemetry-python.readthedocs.io/en/latest/", None, ), - "https": ("https://www.encode.io/httpcore/", None), + "httpcore": ("https://www.encode.io/httpcore/", None), } # http://www.sphinx-doc.org/en/master/config.html#confval-nitpicky From 9a6caed454c6a93690fd23a9a328047bdd2f6a99 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Sat, 24 Apr 2021 14:16:27 -0700 Subject: [PATCH 13/20] Add clearer uninstrument client tests --- .../tests/test_httpx_integration.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index d35680b205..9c54cd6ce7 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -343,9 +343,9 @@ def create_client( pass def setUp(self): + self.client = self.create_client() HTTPXClientInstrumentor().instrument() super().setUp() - self.client = self.create_client() def tearDown(self): super().tearDown() @@ -485,6 +485,14 @@ def test_uninstrument(self): HTTPXClientInstrumentor().instrument() def test_uninstrument_client(self): + HTTPXClientInstrumentor().uninstrument_client(self.client) + + result = self.perform_request(self.URL) + + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + + def test_uninstrument_new_client(self): client1 = self.create_client() HTTPXClientInstrumentor().uninstrument_client(client1) From f27dd97b3b97ec2489ae910dc7d965e5f8fff56f Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Sat, 24 Apr 2021 14:33:10 -0700 Subject: [PATCH 14/20] Update versions --- .../opentelemetry-instrumentation-httpx/setup.cfg | 10 +++++----- .../src/opentelemetry/instrumentation/httpx/version.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg index e9ff93ddd3..c288b014db 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -38,16 +38,16 @@ package_dir= =src packages=find_namespace: install_requires = - opentelemetry-api == 1.0.1.dev0 - opentelemetry-instrumentation == 0.20.dev0 - opentelemetry-semantic-conventions == 0.20.dev0 + opentelemetry-api == 1.2.0.dev0 + opentelemetry-instrumentation == 0.21.dev0 + opentelemetry-semantic-conventions == 0.21.dev0 httpx >= 0.17.0, < 0.18.0 wrapt >= 1.0.0, < 2.0.0 [options.extras_require] test = - opentelemetry-sdk == 1.0.1dev0 - opentelemetry-test == 0.20.dev0 + opentelemetry-sdk == 1.2.0.dev0 + opentelemetry-test == 0.21.dev0 respx ~= 0.16.0 [options.packages.find] diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py index 3b16be5d22..2b08175266 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.20.dev0" +__version__ = "0.21.dev0" From 50843a4d0a945558d8f1dcee868f43d7319d9817 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Sat, 24 Apr 2021 15:04:49 -0700 Subject: [PATCH 15/20] Update request and response hooks Back to only providing the raw arguments and return values instead of using httpx Request and Response objects to avoid any additional overhead or unexpected side effects. --- docs/nitpick-exceptions.ini | 2 - .../README.rst | 37 ++++++++++++- .../instrumentation/httpx/__init__.py | 52 ++++++++++++------- .../tests/test_httpx_integration.py | 29 +++++++---- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/docs/nitpick-exceptions.ini b/docs/nitpick-exceptions.ini index d8aab0ec87..4e16ddd32c 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -19,8 +19,6 @@ class_references= Getter ; - AwsXRayFormat.extract ; httpx changes __module__ causing Sphinx to error and no Sphinx site is available - httpx.Request - httpx.Response httpx.Client httpx.AsyncClient diff --git a/instrumentation/opentelemetry-instrumentation-httpx/README.rst b/instrumentation/opentelemetry-instrumentation-httpx/README.rst index 6794625aa4..722f1d2bea 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/README.rst +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -113,8 +113,14 @@ If you don't want to use the instrumentor class, you can use the transport class Request and response hooks *************************** -The instrumentation supports specifying request and response hooks. These are functions that get called back by the instrumentation right after a Span is created for a request -and right before the span is finished while processing a response. The hooks can be configured as follows: +The instrumentation supports specifying request and response hooks. These are functions that get called back by the instrumentation right after a span is created for a request +and right before the span is finished while processing a response. + +.. note:: + + The request hook receives the raw arguments provided to the transport layer. The response hook receives the raw return values from the transport layer. + +The hooks can be configured as follows: .. code-block:: python @@ -122,14 +128,41 @@ and right before the span is finished while processing a response. The hooks can from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor def request_hook(span, request): + # method, url, headers, stream, extensions = request pass def response_hook(span, request, response): + # method, url, headers, stream, extensions = request + # status_code, headers, stream, extensions = response pass HTTPXClientInstrumentor().instrument(request_hook=request_hook, response_hook=response_hook) +Or if you are using the transport classes directly: + + +.. code-block:: python + + from opentelemetry.instrumentation.httpx import SyncOpenTelemetryTransport + + def request_hook(span, request): + # method, url, headers, stream, extensions = request + pass + + def response_hook(span, request, response): + # method, url, headers, stream, extensions = request + # status_code, headers, stream, extensions = response + pass + + transport = httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + request_hook=request_hook, + response_hook=response_hook + ) + + References ---------- diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 5525bed9aa..e0776b5b14 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -33,8 +33,20 @@ URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes] Headers = typing.List[typing.Tuple[bytes, bytes]] -RequestHook = typing.Callable[[Span, httpx.Request], None] -ResponseHook = typing.Callable[[Span, httpx.Request, httpx.Response], None] +RequestInfo = typing.Tuple[ + bytes, + URL, + typing.Optional[Headers], + typing.Optional[ + typing.Union[httpcore.SyncByteStream, httpcore.AsyncByteStream] + ], + typing.Optional[dict], +] +ResponseInfo = typing.Tuple[ + int, typing.List[typing.Tuple[bytes, bytes]], typing.Iterable[bytes], dict, +] +RequestHook = typing.Callable[[Span, RequestInfo], None] +ResponseHook = typing.Callable[[Span, RequestInfo, ResponseInfo], None] def _get_default_span_name(method: str) -> str: @@ -110,7 +122,13 @@ def request( span_name = _get_default_span_name( span_attributes[SpanAttributes.HTTP_METHOD] ) - request = httpx.Request(method, url, headers=headers, stream=stream) + request = ( + method, + url, + headers, + stream, + ext, + ) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes @@ -127,15 +145,9 @@ def request( _apply_status_code(span, status_code) if self._response_hook is not None: - response = httpx.Response( - status_code=status_code, - headers=headers, - stream=stream, - ext=extensions, - request=request, + self._response_hook( + span, request, (status_code, headers, stream, extensions) ) - response.read() - self._response_hook(span, request, response) return status_code, headers, stream, extensions @@ -187,7 +199,13 @@ async def arequest( span_name = _get_default_span_name( span_attributes[SpanAttributes.HTTP_METHOD] ) - request = httpx.Request(method, url, headers=headers, stream=stream) + request = ( + method, + url, + headers, + stream, + ext, + ) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes @@ -209,15 +227,9 @@ async def arequest( _apply_status_code(span, status_code) if self._response_hook is not None: - response = httpx.Response( - status_code=status_code, - headers=headers, - stream=stream, - ext=extensions, - request=request, + self._response_hook( + span, request, (status_code, headers, stream, extensions) ) - response.read() - self._response_hook(span, request, response) return status_code, headers, stream, extensions diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 9c54cd6ce7..8a3064acc3 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -35,7 +35,12 @@ from opentelemetry.trace import StatusCode if typing.TYPE_CHECKING: - from opentelemetry.instrumentation.httpx import RequestHook, ResponseHook + from opentelemetry.instrumentation.httpx import ( + RequestHook, + RequestInfo, + ResponseHook, + ResponseInfo, + ) from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import TracerProvider from opentelemetry.trace.span import Span @@ -267,10 +272,11 @@ def test_custom_tracer_provider(self): def test_response_hook(self): def response_hook( - span: "Span", request: httpx.Request, response: httpx.Response + span: "Span", request: "RequestInfo", response: "ResponseInfo" ): span.set_attribute( - HTTP_RESPONSE_BODY, response.content.decode("utf-8") + HTTP_RESPONSE_BODY, + b"".join(part for part in response[2]).decode("utf-8"), ) transport = self.create_transport( @@ -293,8 +299,9 @@ def response_hook( ) def test_request_hook(self): - def request_hook(span: "Span", request: httpx.Request): - span.update_name("GET" + str(request.url)) + def request_hook(span: "Span", request: "RequestInfo"): + url = httpx.URL(request[1]) + span.update_name("GET" + str(url)) transport = self.create_transport(request_hook=request_hook) client = self.create_client(transport) @@ -305,7 +312,7 @@ def request_hook(span: "Span", request: httpx.Request): self.assertEqual(span.name, "GET" + self.URL) def test_request_hook_no_span_change(self): - def request_hook(span: "Span", request: httpx.Request): + def request_hook(span: "Span", request: "RequestInfo"): return 123 transport = self.create_transport(request_hook=request_hook) @@ -369,10 +376,11 @@ def test_custom_tracer_provider(self): def test_response_hook(self): def response_hook( - span, request: httpx.Request, response: httpx.Response + span, request: "RequestInfo", response: "ResponseInfo" ): span.set_attribute( - HTTP_RESPONSE_BODY, response.content.decode("utf-8") + HTTP_RESPONSE_BODY, + b"".join(part for part in response[2]).decode("utf-8"), ) HTTPXClientInstrumentor().uninstrument() @@ -396,8 +404,9 @@ def response_hook( ) def test_request_hook(self): - def request_hook(span: "Span", request: httpx.Request): - span.update_name("GET" + str(request.url)) + def request_hook(span: "Span", request: "RequestInfo"): + url = httpx.URL(request[1]) + span.update_name("GET" + str(url)) HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( From 5547d1f5501875c1e8d78152da817a8361139e54 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 1 Jun 2021 14:49:10 -0700 Subject: [PATCH 16/20] Use 0.18.x as the initial base for instrumenting --- docs-requirements.txt | 2 +- docs/conf.py | 1 - docs/nitpick-exceptions.ini | 5 + .../setup.cfg | 4 +- .../setup.py | 8 +- .../instrumentation/httpx/__init__.py | 84 +++++++++----- .../tests/test_httpx_integration.py | 108 ++++++++++-------- 7 files changed, 132 insertions(+), 80 deletions(-) diff --git a/docs-requirements.txt b/docs-requirements.txt index 4150e3cac7..09eaa24c08 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -34,4 +34,4 @@ redis>=2.6 sqlalchemy>=1.0 tornado>=6.0 ddtrace>=0.34.0 -httpx~=0.17.0 \ No newline at end of file +httpx~=0.18.0 \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index b6654ce83b..0c72e82b80 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,7 +97,6 @@ "https://opentelemetry-python.readthedocs.io/en/latest/", None, ), - "httpcore": ("https://www.encode.io/httpcore/", None), } # http://www.sphinx-doc.org/en/master/config.html#confval-nitpicky diff --git a/docs/nitpick-exceptions.ini b/docs/nitpick-exceptions.ini index 4e16ddd32c..72a1be5461 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -21,6 +21,10 @@ class_references= ; httpx changes __module__ causing Sphinx to error and no Sphinx site is available httpx.Client httpx.AsyncClient + httpx.BaseTransport + httpx.AsyncBaseTransport + httpx.SyncByteStream + httpx.AsyncByteStream anys= ; API @@ -39,3 +43,4 @@ anys= BaseInstrumentor ; - instrumentation.* Setter + httpx diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg index c288b014db..c9da2cba5b 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -41,14 +41,14 @@ install_requires = opentelemetry-api == 1.2.0.dev0 opentelemetry-instrumentation == 0.21.dev0 opentelemetry-semantic-conventions == 0.21.dev0 - httpx >= 0.17.0, < 0.18.0 + httpx >= 0.18.0, < 0.19.0 wrapt >= 1.0.0, < 2.0.0 [options.extras_require] test = opentelemetry-sdk == 1.2.0.dev0 opentelemetry-test == 0.21.dev0 - respx ~= 0.16.0 + respx ~= 0.17.0 [options.packages.find] where = src diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.py b/instrumentation/opentelemetry-instrumentation-httpx/setup.py index 4c42ce73f8..3824b04b80 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.py @@ -11,13 +11,19 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + + +# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM templates/instrumentation_setup.py.txt. +# RUN `python scripts/generate_setup.py` TO REGENERATE. + + import os import setuptools BASE_DIR = os.path.dirname(__file__) VERSION_FILENAME = os.path.join( - BASE_DIR, "src", "opentelemetry", "instrumentation", "httpx", "version.py", + BASE_DIR, "src", "opentelemetry", "instrumentation", "httpx", "version.py" ) PACKAGE_INFO = {} with open(VERSION_FILENAME) as f: diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index e0776b5b14..747ee79a3f 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -14,7 +14,6 @@ import typing -import httpcore import httpx import wrapt @@ -37,9 +36,7 @@ bytes, URL, typing.Optional[Headers], - typing.Optional[ - typing.Union[httpcore.SyncByteStream, httpcore.AsyncByteStream] - ], + typing.Optional[typing.Union[httpx.SyncByteStream, httpx.AsyncByteStream]], typing.Optional[dict], ] ResponseInfo = typing.Tuple[ @@ -47,6 +44,12 @@ ] RequestHook = typing.Callable[[Span, RequestInfo], None] ResponseHook = typing.Callable[[Span, RequestInfo, ResponseInfo], None] +AsyncRequestHook = typing.Callable[ + [Span, RequestInfo], typing.Awaitable[typing.Any] +] +AsyncResponseHook = typing.Callable[ + [Span, RequestInfo, ResponseInfo], typing.Awaitable[typing.Any] +] def _get_default_span_name(method: str) -> str: @@ -75,7 +78,7 @@ def _prepare_headers(headers: typing.Optional[Headers]) -> httpx.Headers: return httpx.Headers(headers) -class SyncOpenTelemetryTransport(httpcore.SyncHTTPTransport): +class SyncOpenTelemetryTransport(httpx.BaseTransport): """Sync transport class that will trace all requests made with a client. Args: @@ -89,7 +92,7 @@ class SyncOpenTelemetryTransport(httpcore.SyncHTTPTransport): def __init__( self, - transport: httpcore.SyncHTTPTransport, + transport: httpx.BaseTransport, tracer_provider: typing.Optional[TracerProvider] = None, request_hook: typing.Optional[RequestHook] = None, response_hook: typing.Optional[ResponseHook] = None, @@ -103,18 +106,22 @@ def __init__( self._request_hook = request_hook self._response_hook = response_hook - def request( + def handle_request( self, method: bytes, url: URL, headers: typing.Optional[Headers] = None, - stream: typing.Optional[httpcore.SyncByteStream] = None, - ext: typing.Optional[dict] = None, - ) -> typing.Tuple[int, "Headers", httpcore.SyncByteStream, dict]: + stream: typing.Optional[httpx.SyncByteStream] = None, + extensions: typing.Optional[dict] = None, + ) -> typing.Tuple[int, "Headers", httpx.SyncByteStream, dict]: """Add request info to span.""" if context.get_value("suppress_instrumentation"): - return self._transport.request( - method, url, headers=headers, stream=stream, ext=ext + return self._transport.handle_request( + method, + url, + headers=headers, + stream=stream, + extensions=extensions, ) span_attributes = _prepare_attributes(method, url) @@ -127,7 +134,7 @@ def request( url, headers, stream, - ext, + extensions, ) with self._tracer.start_as_current_span( @@ -138,8 +145,17 @@ def request( inject(_headers) - status_code, headers, stream, extensions = self._transport.request( - method, url, headers=_headers.raw, stream=stream, ext=ext + ( + status_code, + headers, + stream, + extensions, + ) = self._transport.handle_request( + method, + url, + headers=_headers.raw, + stream=stream, + extensions=extensions, ) _apply_status_code(span, status_code) @@ -152,7 +168,7 @@ def request( return status_code, headers, stream, extensions -class AsyncOpenTelemetryTransport(httpcore.AsyncHTTPTransport): +class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): """Async transport class that will trace all requests made with a client. Args: @@ -166,7 +182,7 @@ class AsyncOpenTelemetryTransport(httpcore.AsyncHTTPTransport): def __init__( self, - transport: httpcore.AsyncHTTPTransport, + transport: httpx.AsyncBaseTransport, tracer_provider: typing.Optional[TracerProvider] = None, request_hook: typing.Optional[RequestHook] = None, response_hook: typing.Optional[ResponseHook] = None, @@ -180,18 +196,22 @@ def __init__( self._request_hook = request_hook self._response_hook = response_hook - async def arequest( + async def handle_async_request( self, method: bytes, url: URL, headers: typing.Optional[Headers] = None, - stream: typing.Optional[httpcore.AsyncByteStream] = None, - ext: typing.Optional[dict] = None, - ) -> typing.Tuple[int, "Headers", httpcore.AsyncByteStream, dict]: + stream: typing.Optional[httpx.AsyncByteStream] = None, + extensions: typing.Optional[dict] = None, + ) -> typing.Tuple[int, "Headers", httpx.AsyncByteStream, dict]: """Add request info to span.""" if context.get_value("suppress_instrumentation"): - return await self._transport.arequest( - method, url, headers=headers, stream=stream, ext=ext + return await self._transport.handle_async_request( + method, + url, + headers=headers, + stream=stream, + extensions=extensions, ) span_attributes = _prepare_attributes(method, url) @@ -204,14 +224,14 @@ async def arequest( url, headers, stream, - ext, + extensions, ) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: if self._request_hook is not None: - self._request_hook(span, request) + await self._request_hook(span, request) inject(_headers) @@ -220,14 +240,18 @@ async def arequest( headers, stream, extensions, - ) = await self._transport.arequest( - method, url, headers=_headers.raw, stream=stream, ext=ext + ) = await self._transport.handle_async_request( + method, + url, + headers=_headers.raw, + stream=stream, + extensions=extensions, ) _apply_status_code(span, status_code) if self._response_hook is not None: - self._response_hook( + await self._response_hook( span, request, (status_code, headers, stream, extensions) ) @@ -291,7 +315,7 @@ def _instrument_client( """Enables instrumentation for the given Client or AsyncClient""" # pylint: disable=protected-access if isinstance(client, httpx.Client): - transport = client._transport or httpcore.SyncHTTPTransport() + transport = client._transport or httpx.HTTPTransport() telemetry_transport = SyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, @@ -299,7 +323,7 @@ def _instrument_client( response_hook=response_hook, ) elif isinstance(client, httpx.AsyncClient): - transport = client._transport or httpcore.AsyncHTTPTransport() + transport = client._transport or httpx.AsyncHTTPTransport() telemetry_transport = AsyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 8a3064acc3..e6d8427341 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -36,6 +36,8 @@ if typing.TYPE_CHECKING: from opentelemetry.instrumentation.httpx import ( + AsyncRequestHook, + AsyncResponseHook, RequestHook, RequestInfo, ResponseHook, @@ -49,11 +51,43 @@ HTTP_RESPONSE_BODY = "http.response.body" -def async_call(coro: typing.Coroutine) -> asyncio.Task: +def _async_call(coro: typing.Coroutine) -> asyncio.Task: loop = asyncio.get_event_loop() return loop.run_until_complete(coro) +def _response_hook(span, request: "RequestInfo", response: "ResponseInfo"): + span.set_attribute( + HTTP_RESPONSE_BODY, response[2].read(), + ) + + +async def _async_response_hook( + span: "Span", request: "RequestInfo", response: "ResponseInfo" +): + span.set_attribute( + HTTP_RESPONSE_BODY, await response[2].aread(), + ) + + +def _request_hook(span: "Span", request: "RequestInfo"): + url = httpx.URL(request[1]) + span.update_name("GET" + str(url)) + + +async def _async_request_hook(span: "Span", request: "RequestInfo"): + url = httpx.URL(request[1]) + span.update_name("GET" + str(url)) + + +def _no_update_request_hook(span: "Span", request: "RequestInfo"): + return 123 + + +async def _async_no_update_request_hook(span: "Span", request: "RequestInfo"): + return 123 + + # Using this wrapper class to have a base class for the tests while also not # angering pylint or mypy when calling methods not in the class when only # subclassing abc.ABC. @@ -62,6 +96,9 @@ class BaseTest(TestBase, metaclass=abc.ABCMeta): # pylint: disable=no-member URL = "http://httpbin.org/status/200" + response_hook = staticmethod(_response_hook) + request_hook = staticmethod(_request_hook) + no_update_request_hook = staticmethod(_no_update_request_hook) # pylint: disable=invalid-name def setUp(self): @@ -205,20 +242,21 @@ def test_requests_timeout_exception(self): self.assertEqual(span.status.status_code, StatusCode.ERROR) def test_invalid_url(self): - url = "http://[::1/nope" + url = "invalid://nope" - with respx.mock, self.assertRaises(httpx.LocalProtocolError): - respx.post("http://nope").pass_through() + with respx.mock, self.assertRaises(httpx.UnsupportedProtocol): + respx.post("invalid://nope").pass_through() self.perform_request(url, method="POST") span = self.assert_span() self.assertEqual(span.name, "HTTP POST") + print(span.attributes) self.assertEqual( span.attributes, { SpanAttributes.HTTP_METHOD: "POST", - SpanAttributes.HTTP_URL: "http://nope", + SpanAttributes.HTTP_URL: "invalid://nope/", }, ) self.assertEqual(span.status.status_code, StatusCode.ERROR) @@ -271,17 +309,9 @@ def test_custom_tracer_provider(self): self.assertIs(span.resource, resource) def test_response_hook(self): - def response_hook( - span: "Span", request: "RequestInfo", response: "ResponseInfo" - ): - span.set_attribute( - HTTP_RESPONSE_BODY, - b"".join(part for part in response[2]).decode("utf-8"), - ) - transport = self.create_transport( tracer_provider=self.tracer_provider, - response_hook=response_hook, + response_hook=self.response_hook, ) client = self.create_client(transport) result = self.perform_request(self.URL, client=client) @@ -299,11 +329,7 @@ def response_hook( ) def test_request_hook(self): - def request_hook(span: "Span", request: "RequestInfo"): - url = httpx.URL(request[1]) - span.update_name("GET" + str(url)) - - transport = self.create_transport(request_hook=request_hook) + transport = self.create_transport(request_hook=self.request_hook) client = self.create_client(transport) result = self.perform_request(self.URL, client=client) @@ -312,10 +338,9 @@ def request_hook(span: "Span", request: "RequestInfo"): self.assertEqual(span.name, "GET" + self.URL) def test_request_hook_no_span_change(self): - def request_hook(span: "Span", request: "RequestInfo"): - return 123 - - transport = self.create_transport(request_hook=request_hook) + transport = self.create_transport( + request_hook=self.no_update_request_hook + ) client = self.create_client(transport) result = self.perform_request(self.URL, client=client) @@ -375,18 +400,10 @@ def test_custom_tracer_provider(self): self.assertIs(span.resource, resource) def test_response_hook(self): - def response_hook( - span, request: "RequestInfo", response: "ResponseInfo" - ): - span.set_attribute( - HTTP_RESPONSE_BODY, - b"".join(part for part in response[2]).decode("utf-8"), - ) - HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( tracer_provider=self.tracer_provider, - response_hook=response_hook, + response_hook=self.response_hook, ) client = self.create_client() result = self.perform_request(self.URL, client=client) @@ -404,14 +421,10 @@ def response_hook( ) def test_request_hook(self): - def request_hook(span: "Span", request: "RequestInfo"): - url = httpx.URL(request[1]) - span.update_name("GET" + str(url)) - HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( tracer_provider=self.tracer_provider, - request_hook=request_hook, + request_hook=self.request_hook, ) client = self.create_client() result = self.perform_request(self.URL, client=client) @@ -421,13 +434,10 @@ def request_hook(span: "Span", request: "RequestInfo"): self.assertEqual(span.name, "GET" + self.URL) def test_request_hook_no_span_update(self): - def request_hook(span: "Span", request: httpx.Request): - return 123 - HTTPXClientInstrumentor().uninstrument() HTTPXClientInstrumentor().instrument( tracer_provider=self.tracer_provider, - request_hook=request_hook, + request_hook=self.no_update_request_hook, ) client = self.create_client() result = self.perform_request(self.URL, client=client) @@ -566,6 +576,10 @@ def perform_request( class TestAsyncIntegration(BaseTestCases.BaseManualTest): + response_hook = staticmethod(_async_response_hook) + request_hook = staticmethod(_async_request_hook) + no_update_request_hook = staticmethod(_async_no_update_request_hook) + def setUp(self): super().setUp() self.transport = self.create_transport() @@ -574,8 +588,8 @@ def setUp(self): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, - request_hook: typing.Optional["RequestHook"] = None, - response_hook: typing.Optional["ResponseHook"] = None, + request_hook: typing.Optional["AsyncRequestHook"] = None, + response_hook: typing.Optional["AsyncResponseHook"] = None, ): transport = httpx.AsyncHTTPTransport() telemetry_transport = AsyncOpenTelemetryTransport( @@ -606,7 +620,7 @@ async def _perform_request(): async with client as _client: return await _client.request(method, url, headers=headers) - return async_call(_perform_request()) + return _async_call(_perform_request()) class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest): @@ -628,6 +642,10 @@ def perform_request( class TestAsyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest): + response_hook = staticmethod(_async_response_hook) + request_hook = staticmethod(_async_request_hook) + no_update_request_hook = staticmethod(_async_no_update_request_hook) + def create_client( self, transport: typing.Optional[AsyncOpenTelemetryTransport] = None, ): @@ -648,4 +666,4 @@ async def _perform_request(): async with client as _client: return await _client.request(method, url, headers=headers) - return async_call(_perform_request()) + return _async_call(_perform_request()) From 9761617d2c324c9338f71e158238a7f3bb3c9d2c Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 1 Jun 2021 15:34:52 -0700 Subject: [PATCH 17/20] Update based on packaging changes --- CHANGELOG.md | 4 +- .../setup.cfg | 11 ++-- .../setup.py | 61 ++++++++++++++++++- .../instrumentation/httpx/__init__.py | 4 ++ .../instrumentation/httpx/package.py | 16 +++++ .../instrumentation/httpx/version.py | 2 +- .../instrumentation/bootstrap_gen.py | 4 ++ tox.ini | 2 +- 8 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2607110e2a..385ea8ceb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-botocore` now supports context propagation for lambda invoke via Payload embedded headers. ([#458](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/458)) +- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation + ([#461](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/461)) ## [0.21b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.2.0-0.21b0) - 2021-05-11 @@ -46,8 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Move `opentelemetry-instrumentation` from core repository ([#465](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/465)) -- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation - ([#461](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/461)) ## [0.20b0](https://github.com/open-telemetry/opentelemetry-python-contrib/releases/tag/v0.20b0) - 2021-04-20 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg index c9da2cba5b..9d38204f8f 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -38,16 +38,15 @@ package_dir= =src packages=find_namespace: install_requires = - opentelemetry-api == 1.2.0.dev0 - opentelemetry-instrumentation == 0.21.dev0 - opentelemetry-semantic-conventions == 0.21.dev0 - httpx >= 0.18.0, < 0.19.0 + opentelemetry-api == 1.3.0.dev0 + opentelemetry-instrumentation == 0.22.dev0 + opentelemetry-semantic-conventions == 0.22.dev0 wrapt >= 1.0.0, < 2.0.0 [options.extras_require] test = - opentelemetry-sdk == 1.2.0.dev0 - opentelemetry-test == 0.21.dev0 + opentelemetry-sdk == 1.3.0.dev0 + opentelemetry-test == 0.22.dev0 respx ~= 0.17.0 [options.packages.find] diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.py b/instrumentation/opentelemetry-instrumentation-httpx/setup.py index 3824b04b80..26a48970fa 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.py @@ -17,16 +17,73 @@ # RUN `python scripts/generate_setup.py` TO REGENERATE. +import distutils.cmd +import json import os +from configparser import ConfigParser import setuptools +config = ConfigParser() +config.read("setup.cfg") + +# We provide extras_require parameter to setuptools.setup later which +# overwrites the extra_require section from setup.cfg. To support extra_require +# secion in setup.cfg, we load it here and merge it with the extra_require param. +extras_require = {} +if "options.extras_require" in config: + for key, value in config["options.extras_require"].items(): + extras_require[key] = [v for v in value.split("\n") if v.strip()] + BASE_DIR = os.path.dirname(__file__) +PACKAGE_INFO = {} + VERSION_FILENAME = os.path.join( BASE_DIR, "src", "opentelemetry", "instrumentation", "httpx", "version.py" ) -PACKAGE_INFO = {} with open(VERSION_FILENAME) as f: exec(f.read(), PACKAGE_INFO) -setuptools.setup(version=PACKAGE_INFO["__version__"]) +PACKAGE_FILENAME = os.path.join( + BASE_DIR, "src", "opentelemetry", "instrumentation", "httpx", "package.py" +) +with open(PACKAGE_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +# Mark any instruments/runtime dependencies as test dependencies as well. +extras_require["instruments"] = PACKAGE_INFO["_instruments"] +test_deps = extras_require.get("test", []) +for dep in extras_require["instruments"]: + test_deps.append(dep) + +extras_require["test"] = test_deps + + +class JSONMetadataCommand(distutils.cmd.Command): + + description = ( + "print out package metadata as JSON. This is used by OpenTelemetry dev scripts to ", + "auto-generate code in other places", + ) + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + metadata = { + "name": config["metadata"]["name"], + "version": PACKAGE_INFO["__version__"], + "instruments": PACKAGE_INFO["_instruments"], + } + print(json.dumps(metadata)) + + +setuptools.setup( + cmdclass={"meta": JSONMetadataCommand}, + version=PACKAGE_INFO["__version__"], + extras_require=extras_require, +) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 747ee79a3f..1938f4f965 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -18,6 +18,7 @@ import wrapt from opentelemetry import context +from opentelemetry.instrumentation.httpx.package import _instruments from opentelemetry.instrumentation.httpx.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.utils import ( @@ -355,6 +356,9 @@ class HTTPXClientInstrumentor(BaseInstrumentor): See `BaseInstrumentor` """ + def instrumentation_dependencies(self) -> typing.Collection[str]: + return _instruments + def _instrument(self, **kwargs): """Instruments httpx Client and AsyncClient diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py new file mode 100644 index 0000000000..08bbe77f9c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py @@ -0,0 +1,16 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +_instruments = ("httpx >= 0.18.0, < 0.19.0",) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py index 2b08175266..ba922d2bed 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.21.dev0" +__version__ = "0.22.dev0" diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index b94ec7d1ab..e2c6e47ec1 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -68,6 +68,10 @@ "library": "grpcio ~= 1.27", "instrumentation": "opentelemetry-instrumentation-grpc==0.22.dev0", }, + "httpx": { + "library": "httpx >= 0.18.0, < 0.19.0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.22.dev0", + }, "jinja2": { "library": "jinja2~=2.7", "instrumentation": "opentelemetry-instrumentation-jinja2==0.22.dev0", diff --git a/tox.ini b/tox.ini index 7db64adcb8..3816d7b1bf 100644 --- a/tox.ini +++ b/tox.ini @@ -145,7 +145,7 @@ envlist = pypy3-test-instrumentation-tornado ; opentelemetry-instrumentation-httpx - py{6,7,8}-test-instrumentation-httpx + py3{6,7,8,9}-test-instrumentation-httpx pypy3-test-instrumentation-httpx ; opentelemetry-util-http From 940097cfdc1097e80380f61a390bbcc1ce61543b Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Tue, 1 Jun 2021 16:47:20 -0700 Subject: [PATCH 18/20] Use namedtuple for request and response info Easier usage in hooks --- .../instrumentation/httpx/__init__.py | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 1938f4f965..fa3d29faf2 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -33,26 +33,33 @@ URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes] Headers = typing.List[typing.Tuple[bytes, bytes]] -RequestInfo = typing.Tuple[ - bytes, - URL, - typing.Optional[Headers], - typing.Optional[typing.Union[httpx.SyncByteStream, httpx.AsyncByteStream]], - typing.Optional[dict], -] -ResponseInfo = typing.Tuple[ - int, typing.List[typing.Tuple[bytes, bytes]], typing.Iterable[bytes], dict, -] -RequestHook = typing.Callable[[Span, RequestInfo], None] -ResponseHook = typing.Callable[[Span, RequestInfo, ResponseInfo], None] +RequestHook = typing.Callable[[Span, "RequestInfo"], None] +ResponseHook = typing.Callable[[Span, "RequestInfo", "ResponseInfo"], None] AsyncRequestHook = typing.Callable[ - [Span, RequestInfo], typing.Awaitable[typing.Any] + [Span, "RequestInfo"], typing.Awaitable[typing.Any] ] AsyncResponseHook = typing.Callable[ - [Span, RequestInfo, ResponseInfo], typing.Awaitable[typing.Any] + [Span, "RequestInfo", "ResponseInfo"], typing.Awaitable[typing.Any] ] +class RequestInfo(typing.NamedTuple): + method: bytes + url: URL + headers: typing.Optional[Headers] + stream: typing.Optional[ + typing.Union[httpx.SyncByteStream, httpx.AsyncByteStream] + ] + extensions: typing.Optional[dict] + + +class ResponseInfo(typing.NamedTuple): + status_code: int + headers: typing.Optional[Headers] + stream: typing.Iterable[bytes] + extensions: typing.Optional[dict] + + def _get_default_span_name(method: str) -> str: return "HTTP {}".format(method).strip() @@ -130,13 +137,7 @@ def handle_request( span_name = _get_default_span_name( span_attributes[SpanAttributes.HTTP_METHOD] ) - request = ( - method, - url, - headers, - stream, - extensions, - ) + request = RequestInfo(method, url, headers, stream, extensions) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes @@ -163,7 +164,9 @@ def handle_request( if self._response_hook is not None: self._response_hook( - span, request, (status_code, headers, stream, extensions) + span, + request, + ResponseInfo(status_code, headers, stream, extensions), ) return status_code, headers, stream, extensions @@ -220,13 +223,7 @@ async def handle_async_request( span_name = _get_default_span_name( span_attributes[SpanAttributes.HTTP_METHOD] ) - request = ( - method, - url, - headers, - stream, - extensions, - ) + request = RequestInfo(method, url, headers, stream, extensions) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes @@ -253,7 +250,9 @@ async def handle_async_request( if self._response_hook is not None: await self._response_hook( - span, request, (status_code, headers, stream, extensions) + span, + request, + ResponseInfo(status_code, headers, stream, extensions), ) return status_code, headers, stream, extensions From d23e247d45b88b4bec7027a89b13849ab62a8477 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Wed, 2 Jun 2021 11:11:11 -0700 Subject: [PATCH 19/20] Update versions --- .../opentelemetry-instrumentation-httpx/setup.cfg | 10 +++++----- .../src/opentelemetry/instrumentation/httpx/version.py | 2 +- .../src/opentelemetry/instrumentation/bootstrap_gen.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg index 9d38204f8f..0bfbd261ff 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -38,15 +38,15 @@ package_dir= =src packages=find_namespace: install_requires = - opentelemetry-api == 1.3.0.dev0 - opentelemetry-instrumentation == 0.22.dev0 - opentelemetry-semantic-conventions == 0.22.dev0 + opentelemetry-api == 1.4.0.dev0 + opentelemetry-instrumentation == 0.23.dev0 + opentelemetry-semantic-conventions == 0.23.dev0 wrapt >= 1.0.0, < 2.0.0 [options.extras_require] test = - opentelemetry-sdk == 1.3.0.dev0 - opentelemetry-test == 0.22.dev0 + opentelemetry-sdk == 1.4.0.dev0 + opentelemetry-test == 0.23.dev0 respx ~= 0.17.0 [options.packages.find] diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py index ba922d2bed..c829b95757 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.22.dev0" +__version__ = "0.23.dev0" diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 2dc9044b98..e5a3293c3b 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -70,7 +70,7 @@ }, "httpx": { "library": "httpx >= 0.18.0, < 0.19.0", - "instrumentation": "opentelemetry-instrumentation-httpx==0.22.dev0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.23.dev0", }, "jinja2": { "library": "jinja2~=2.7", From c8d08059420f7e42c0110c0125187c562d9717f2 Mon Sep 17 00:00:00 2001 From: Joshua Stiefer Date: Mon, 7 Jun 2021 23:15:04 -0700 Subject: [PATCH 20/20] Updates - Move changelog entry - Fix license copyright - Add Python 3.9 classifier --- CHANGELOG.md | 6 ++++-- instrumentation/opentelemetry-instrumentation-httpx/LICENSE | 2 +- .../opentelemetry-instrumentation-httpx/setup.cfg | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24575b2d6c..c8d2a28508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix weak reference error for pyodbc cursor in SQLAlchemy instrumentation. ([#469](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/469)) +### Added +- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation + ([#461](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/461)) + ## [0.22b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.3.0-0.22b0) - 2021-06-01 ### Changed @@ -37,8 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-botocore` now supports context propagation for lambda invoke via Payload embedded headers. ([#458](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/458)) -- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation - ([#461](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/461)) ## [0.21b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.2.0-0.21b0) - 2021-05-11 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/LICENSE b/instrumentation/opentelemetry-instrumentation-httpx/LICENSE index 261eeb9e9f..1ef7dad2c5 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/LICENSE +++ b/instrumentation/opentelemetry-instrumentation-httpx/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright The OpenTelemetry Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg index 0bfbd261ff..cdc5dad7d9 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -31,6 +31,7 @@ classifiers = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 [options] python_requires = >=3.6