diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba0b4806eb..6e9dccc821 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 653f3f9a07..4cf3ac27e1 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
@@ -294,6 +299,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]
@@ -380,6 +387,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]