From 9695bcfed37d7ce99a40d5ce4b23668d25a5192f Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 8 Jun 2021 08:28:03 -0700 Subject: [PATCH] Add support for HTTPX instrumentation (#461) --- CHANGELOG.md | 4 + docs-requirements.txt | 1 + docs/instrumentation/httpx/httpx.rst | 10 + docs/nitpick-exceptions.ini | 8 + .../LICENSE | 201 ++++++ .../MANIFEST.in | 9 + .../README.rst | 170 +++++ .../setup.cfg | 58 ++ .../setup.py | 89 +++ .../instrumentation/httpx/__init__.py | 414 +++++++++++ .../instrumentation/httpx/package.py | 16 + .../instrumentation/httpx/version.py | 15 + .../tests/__init__.py | 0 .../tests/test_httpx_integration.py | 669 ++++++++++++++++++ .../instrumentation/bootstrap_gen.py | 4 + tox.ini | 8 + 16 files changed, 1676 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/package.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/CHANGELOG.md b/CHANGELOG.md index feb865a13c..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 diff --git a/docs-requirements.txt b/docs-requirements.txt index 5dafd0e0eb..09eaa24c08 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.18.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..72a1be5461 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -18,6 +18,13 @@ class_references= Setter Getter ; - AwsXRayFormat.extract + ; 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 @@ -36,3 +43,4 @@ anys= BaseInstrumentor ; - instrumentation.* Setter + httpx diff --git a/instrumentation/opentelemetry-instrumentation-httpx/LICENSE b/instrumentation/opentelemetry-instrumentation-httpx/LICENSE new file mode 100644 index 0000000000..1ef7dad2c5 --- /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 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. 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..722f1d2bea --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -0,0 +1,170 @@ +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 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) + +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. + +.. 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 clients + HTTPXClientInstrumentor().uninstrument() + + +Using transports directly +************************* + +If you don't want to use the instrumentor class, you can use the transport classes directly. + + +.. 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) + + +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. + +.. 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 + + 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 +---------- + +* `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..cdc5dad7d9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.cfg @@ -0,0 +1,58 @@ +# 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 + Programming Language :: Python :: 3.9 + +[options] +python_requires = >=3.6 +package_dir= + =src +packages=find_namespace: +install_requires = + 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.4.0.dev0 + opentelemetry-test == 0.23.dev0 + respx ~= 0.17.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..26a48970fa --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/setup.py @@ -0,0 +1,89 @@ +# 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. + + +# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM templates/instrumentation_setup.py.txt. +# 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" +) +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +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 new file mode 100644 index 0000000000..fa3d29faf2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -0,0 +1,414 @@ +# 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 httpx +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 ( + http_status_to_status_code, + 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 + +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] +AsyncRequestHook = typing.Callable[ + [Span, "RequestInfo"], typing.Awaitable[typing.Any] +] +AsyncResponseHook = typing.Callable[ + [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() + + +def _apply_status_code(span: Span, status_code: int) -> None: + if not span.is_recording(): + return + + span.set_attribute(SpanAttributes.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 = { + SpanAttributes.HTTP_METHOD: _method, + SpanAttributes.HTTP_URL: _url, + } + return span_attributes + + +def _prepare_headers(headers: typing.Optional[Headers]) -> httpx.Headers: + return httpx.Headers(headers) + + +class SyncOpenTelemetryTransport(httpx.BaseTransport): + """Sync transport class that will trace all requests made with a client. + + Args: + transport: SyncHTTPTransport instance to wrap + tracer_provider: Tracer provider to use + 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: httpx.BaseTransport, + tracer_provider: typing.Optional[TracerProvider] = 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._request_hook = request_hook + self._response_hook = response_hook + + def handle_request( + self, + method: bytes, + url: URL, + headers: typing.Optional[Headers] = None, + 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.handle_request( + method, + url, + headers=headers, + stream=stream, + extensions=extensions, + ) + + span_attributes = _prepare_attributes(method, url) + _headers = _prepare_headers(headers) + span_name = _get_default_span_name( + span_attributes[SpanAttributes.HTTP_METHOD] + ) + request = RequestInfo(method, url, headers, stream, 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) + + inject(_headers) + + ( + status_code, + headers, + stream, + extensions, + ) = self._transport.handle_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( + span, + request, + ResponseInfo(status_code, headers, stream, extensions), + ) + + return status_code, headers, stream, extensions + + +class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): + """Async transport class that will trace all requests made with a client. + + Args: + transport: AsyncHTTPTransport instance to wrap + tracer_provider: Tracer provider to use + 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: httpx.AsyncBaseTransport, + tracer_provider: typing.Optional[TracerProvider] = 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._request_hook = request_hook + self._response_hook = response_hook + + async def handle_async_request( + self, + method: bytes, + url: URL, + headers: typing.Optional[Headers] = None, + 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.handle_async_request( + method, + url, + headers=headers, + stream=stream, + extensions=extensions, + ) + + span_attributes = _prepare_attributes(method, url) + _headers = _prepare_headers(headers) + span_name = _get_default_span_name( + span_attributes[SpanAttributes.HTTP_METHOD] + ) + request = RequestInfo(method, url, headers, stream, 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: + await self._request_hook(span, request) + + inject(_headers) + + ( + status_code, + headers, + stream, + extensions, + ) = 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: + await self._response_hook( + span, + request, + ResponseInfo(status_code, headers, stream, extensions), + ) + + return status_code, headers, stream, extensions + + +def _instrument( + tracer_provider: TracerProvider = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = 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_send(wrapped, instance, args, kwargs): + if context.get_value("suppress_instrumentation"): + return wrapped(*args, **kwargs) + + transport = instance._transport or httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + + instance._transport = telemetry_transport + return wrapped(*args, **kwargs) + + async def instrumented_async_send(wrapped, instance, args, kwargs): + if context.get_value("suppress_instrumentation"): + return await wrapped(*args, **kwargs) + + transport = instance._transport or httpx.AsyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + + instance._transport = telemetry_transport + return await wrapped(*args, **kwargs) + + wrapt.wrap_function_wrapper(httpx.Client, "send", instrumented_sync_send) + + wrapt.wrap_function_wrapper( + httpx.AsyncClient, "send", instrumented_async_send + ) + + +def _instrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient], + tracer_provider: TracerProvider = 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 + if isinstance(client, httpx.Client): + transport = client._transport or httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + elif isinstance(client, httpx.AsyncClient): + transport = client._transport or httpx.AsyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + 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, "send") + unwrap(httpx.AsyncClient, "send") + + +def _uninstrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient] +) -> None: + """Disables instrumentation for the given Client or AsyncClient""" + # pylint: disable=protected-access + unwrap(client, "send") + + +class HTTPXClientInstrumentor(BaseInstrumentor): + """An instrumentor for httpx Client and AsyncClient + + See `BaseInstrumentor` + """ + + def instrumentation_dependencies(self) -> typing.Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + """Instruments httpx Client and AsyncClient + + Args: + **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( + tracer_provider=kwargs.get("tracer_provider"), + request_hook=kwargs.get("request_hook"), + response_hook=kwargs.get("response_hook"), + ) + + def _uninstrument(self, **kwargs): + _uninstrument() + + @staticmethod + def instrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient], + tracer_provider: TracerProvider = None, + request_hook: typing.Optional[RequestHook] = None, + response_hook: typing.Optional[ResponseHook] = None, + ) -> None: + """Instrument httpx Client or AsyncClient + + Args: + client: The httpx Client or AsyncClient instance + 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=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + + @staticmethod + def uninstrument_client( + client: typing.Union[httpx.Client, httpx.AsyncClient] + ): + """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/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 new file mode 100644 index 0000000000..c829b95757 --- /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.23.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..e6d8427341 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -0,0 +1,669 @@ +# 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.semconv.trace import SpanAttributes +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 ( + AsyncRequestHook, + AsyncResponseHook, + RequestHook, + RequestInfo, + ResponseHook, + ResponseInfo, + ) + from opentelemetry.sdk.trace.export import SpanExporter + from opentelemetry.trace import TracerProvider + 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) + + +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. +class BaseTestCases: + 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): + 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, + { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.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(SpanAttributes.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, + { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.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 = "invalid://nope" + + 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: "invalid://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, + request_hook: typing.Optional["RequestHook"] = None, + response_hook: typing.Optional["ResponseHook"] = 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_response_hook(self): + transport = self.create_transport( + tracer_provider=self.tracer_provider, + response_hook=self.response_hook, + ) + 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, + { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_BODY: "Hello!", + }, + ) + + def test_request_hook(self): + transport = self.create_transport(request_hook=self.request_hook) + 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_request_hook_no_span_change(self): + transport = self.create_transport( + request_hook=self.no_update_request_hook + ) + 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: + transport = self.create_transport( + tracer_provider=trace._DefaultTracerProvider() + ) + 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): + self.client = self.create_client() + HTTPXClientInstrumentor().instrument() + super().setUp() + + 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_response_hook(self): + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + response_hook=self.response_hook, + ) + 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, + { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_URL: self.URL, + SpanAttributes.HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_BODY: "Hello!", + }, + ) + + def test_request_hook(self): + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + request_hook=self.request_hook, + ) + 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_request_hook_no_span_update(self): + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + request_hook=self.no_update_request_hook, + ) + 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: + HTTPXClientInstrumentor().uninstrument() + HTTPXClientInstrumentor().instrument( + tracer_provider=trace._DefaultTracerProvider() + ) + 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_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() + 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() + result = self.perform_request(self.URL) + 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): + 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) + + 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, + request_hook: typing.Optional["RequestHook"] = None, + response_hook: typing.Optional["ResponseHook"] = None, + ): + transport = httpx.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + 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): + 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() + self.client = self.create_client(self.transport) + + def create_transport( + self, + tracer_provider: typing.Optional["TracerProvider"] = None, + request_hook: typing.Optional["AsyncRequestHook"] = None, + response_hook: typing.Optional["AsyncResponseHook"] = None, + ): + transport = httpx.AsyncHTTPTransport() + telemetry_transport = AsyncOpenTelemetryTransport( + transport, + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + 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): + 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, + ): + 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/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index a7d4d54000..e5a3293c3b 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.23.dev0", }, + "httpx": { + "library": "httpx >= 0.18.0, < 0.19.0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.23.dev0", + }, "jinja2": { "library": "jinja2~=2.7", "instrumentation": "opentelemetry-instrumentation-jinja2==0.23.dev0", diff --git a/tox.ini b/tox.ini index bf3f289f91..72778e3ca1 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 + py3{6,7,8,9}-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}/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]