diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml
index d9f90d57d8..4e693695eb 100644
--- a/.github/workflows/core_contrib_test.yml
+++ b/.github/workflows/core_contrib_test.yml
@@ -1707,6 +1707,36 @@ jobs:
- name: Run tests
run: tox -e py310-test-instrumentation-requests -- -ra
+ py310-test-instrumentation-niquests:
+ name: instrumentation-niquests
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }}
+ uses: actions/checkout@v4
+ with:
+ repository: open-telemetry/opentelemetry-python-contrib
+ ref: ${{ env.CONTRIB_REPO_SHA }}
+
+ - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }}
+ uses: actions/checkout@v4
+ with:
+ repository: open-telemetry/opentelemetry-python
+ ref: ${{ env.CORE_REPO_SHA }}
+ path: opentelemetry-python
+
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ architecture: "x64"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e py310-test-instrumentation-niquests -- -ra
+
py310-test-instrumentation-starlette-oldest:
name: instrumentation-starlette-oldest
runs-on: ubuntu-latest
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 6bf3c631cd..52459264f8 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -556,6 +556,25 @@ jobs:
- name: Run tests
run: tox -e lint-instrumentation-requests
+ lint-instrumentation-niquests:
+ name: instrumentation-niquests
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.14
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.14"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e lint-instrumentation-niquests
+
lint-instrumentation-starlette:
name: instrumentation-starlette
runs-on: ubuntu-latest
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9c694007ea..0b32111474 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -5686,6 +5686,101 @@ jobs:
- name: Run tests
run: tox -e py314-test-instrumentation-requests -- -ra
+ py310-test-instrumentation-niquests_ubuntu-latest:
+ name: instrumentation-niquests 3.10 Ubuntu
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e py310-test-instrumentation-niquests -- -ra
+
+ py311-test-instrumentation-niquests_ubuntu-latest:
+ name: instrumentation-niquests 3.11 Ubuntu
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e py311-test-instrumentation-niquests -- -ra
+
+ py312-test-instrumentation-niquests_ubuntu-latest:
+ name: instrumentation-niquests 3.12 Ubuntu
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e py312-test-instrumentation-niquests -- -ra
+
+ py313-test-instrumentation-niquests_ubuntu-latest:
+ name: instrumentation-niquests 3.13 Ubuntu
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.13
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e py313-test-instrumentation-niquests -- -ra
+
+ py314-test-instrumentation-niquests_ubuntu-latest:
+ name: instrumentation-niquests 3.14 Ubuntu
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.14
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.14"
+
+ - name: Install tox
+ run: pip install tox-uv
+
+ - name: Run tests
+ run: tox -e py314-test-instrumentation-niquests -- -ra
+
py310-test-instrumentation-starlette-oldest_ubuntu-latest:
name: instrumentation-starlette-oldest 3.10 Ubuntu
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64da93791c..010ff04580 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- `opentelemetry-instrumentation-niquests`: Add instrumentation for the niquests HTTP client library with sync/async support, TLS span attributes, OCSP revocation status, and connection sub-duration histogram metrics
+ ([#4459](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4459))
- Bump `pylint` to `4.0.5`
([#4244](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4244))
- `opentelemetry-instrumentation-sqlite3`: Add uninstrument, error status, suppress, and no-op tests
diff --git a/docs-requirements.txt b/docs-requirements.txt
index 9d0be53704..3e98dbc123 100644
--- a/docs-requirements.txt
+++ b/docs-requirements.txt
@@ -32,6 +32,7 @@ flask~=2.0
falcon~=2.0
grpcio~=1.27
httpx>=0.18.0
+niquests>=3,<4
kafka-python>=2.0,<3.0
mysql-connector-python~=8.0
mysqlclient~=2.1.1
diff --git a/docs/instrumentation/niquests/niquests.rst b/docs/instrumentation/niquests/niquests.rst
new file mode 100644
index 0000000000..c5bd37db42
--- /dev/null
+++ b/docs/instrumentation/niquests/niquests.rst
@@ -0,0 +1,7 @@
+OpenTelemetry niquests Instrumentation
+======================================
+
+.. automodule:: opentelemetry.instrumentation.niquests
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/instrumentation/README.md b/instrumentation/README.md
index 8d9a247945..d169db5114 100644
--- a/instrumentation/README.md
+++ b/instrumentation/README.md
@@ -30,6 +30,7 @@
| [opentelemetry-instrumentation-logging](./opentelemetry-instrumentation-logging) | logging | No | development
| [opentelemetry-instrumentation-mysql](./opentelemetry-instrumentation-mysql) | mysql-connector-python >= 8.0, < 10.0 | No | development
| [opentelemetry-instrumentation-mysqlclient](./opentelemetry-instrumentation-mysqlclient) | mysqlclient < 3 | No | development
+| [opentelemetry-instrumentation-niquests](./opentelemetry-instrumentation-niquests) | niquests >= 3.0, < 4 | Yes | migration
| [opentelemetry-instrumentation-pika](./opentelemetry-instrumentation-pika) | pika >= 0.12.0 | No | development
| [opentelemetry-instrumentation-psycopg](./opentelemetry-instrumentation-psycopg) | psycopg >= 3.1.0 | No | development
| [opentelemetry-instrumentation-psycopg2](./opentelemetry-instrumentation-psycopg2) | psycopg2 >= 2.7.3.1,psycopg2-binary >= 2.7.3.1 | No | development
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/LICENSE b/instrumentation/opentelemetry-instrumentation-niquests/LICENSE
new file mode 100644
index 0000000000..261eeb9e9f
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/README.rst b/instrumentation/opentelemetry-instrumentation-niquests/README.rst
new file mode 100644
index 0000000000..31ece0cfd2
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/README.rst
@@ -0,0 +1,48 @@
+OpenTelemetry Niquests Instrumentation
+======================================
+
+|pypi|
+
+.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-niquests.svg
+ :target: https://pypi.org/project/opentelemetry-instrumentation-niquests/
+
+This library allows tracing HTTP requests made by the
+`niquests `_ library.
+
+Niquests is a drop-in replacement for the ``requests`` library with
+native async support, HTTP/2, and HTTP/3 capabilities. Both synchronous
+and asynchronous interfaces are instrumented.
+
+Lazy responses (e.g. ``Session(multiplexed=True)``) receive a span and
+connection-level attributes (IP, TLS) but **status code and response headers
+are not captured** because they are not yet available.
+
+Installation
+------------
+
+::
+
+ pip install opentelemetry-instrumentation-niquests
+
+Configuration
+-------------
+
+Exclude lists
+*************
+To exclude certain URLs from being tracked, set the environment variable ``OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS``
+(or ``OTEL_PYTHON_EXCLUDED_URLS`` as fallback) with comma delimited regexes representing which URLs to exclude.
+
+For example,
+
+::
+
+ export OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS="client/.*/info,healthcheck"
+
+will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``.
+
+References
+----------
+
+* `OpenTelemetry niquests Instrumentation `_
+* `OpenTelemetry Project `_
+* `OpenTelemetry Python Examples `_
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/pyproject.toml b/instrumentation/opentelemetry-instrumentation-niquests/pyproject.toml
new file mode 100644
index 0000000000..8dc6c7ac94
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/pyproject.toml
@@ -0,0 +1,56 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "opentelemetry-instrumentation-niquests"
+dynamic = ["version"]
+description = "OpenTelemetry niquests instrumentation"
+readme = "README.rst"
+license = "Apache-2.0"
+requires-python = ">=3.10"
+authors = [
+ { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" },
+]
+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.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+]
+dependencies = [
+ "opentelemetry-api ~= 1.12",
+ "opentelemetry-instrumentation == 0.63b0.dev",
+ "opentelemetry-semantic-conventions == 0.63b0.dev",
+ "opentelemetry-util-http == 0.63b0.dev",
+]
+
+[project.optional-dependencies]
+instruments = [
+ "niquests >= 3.0, < 4",
+]
+
+[project.entry-points.opentelemetry_instrumentor]
+niquests = "opentelemetry.instrumentation.niquests:NiquestsInstrumentor"
+
+[project.urls]
+Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-niquests"
+Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib"
+
+[tool.hatch.version]
+path = "src/opentelemetry/instrumentation/niquests/version.py"
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "/src",
+ "/tests",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/opentelemetry"]
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/__init__.py b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/__init__.py
new file mode 100644
index 0000000000..add1f63f20
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/__init__.py
@@ -0,0 +1,994 @@
+# 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.
+
+"""
+This library allows tracing HTTP requests made by the
+`niquests `_ library.
+
+Niquests is a drop-in replacement for the ``requests`` library with
+native async support, HTTP/2, and HTTP/3 capabilities.
+
+Usage
+-----
+
+.. code-block:: python
+
+ import niquests
+ from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+
+ # You can optionally pass a custom TracerProvider to instrument().
+ NiquestsInstrumentor().instrument()
+ response = niquests.get(url="https://www.example.org/")
+
+Async usage:
+
+.. code-block:: python
+
+ import niquests
+ from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+
+ NiquestsInstrumentor().instrument()
+
+ async with niquests.AsyncSession() as session:
+ response = await session.get("https://www.example.org/")
+
+Configuration
+-------------
+
+Request/Response hooks
+**********************
+
+The niquests instrumentation supports extending tracing behavior with the help of
+request and response hooks. These are functions that are called back by the instrumentation
+right after a Span is created for a request and right before the span is finished processing a response respectively.
+The hooks can be configured as follows:
+
+.. code:: python
+
+ import niquests
+ from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+
+ # `request_obj` is an instance of niquests.PreparedRequest
+ def request_hook(span, request_obj):
+ pass
+
+ # `request_obj` is an instance of niquests.PreparedRequest
+ # `response` is an instance of niquests.Response
+ def response_hook(span, request_obj, response):
+ pass
+
+ NiquestsInstrumentor().instrument(
+ request_hook=request_hook, response_hook=response_hook
+ )
+
+Capture HTTP request and response headers
+*****************************************
+You can configure the agent to capture specified HTTP headers as span attributes, according to the
+`semantic conventions `_.
+
+Request headers
+***************
+To capture HTTP request headers as span attributes, set the environment variable
+``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST`` to a comma delimited list of HTTP header names.
+
+For example using the environment variable,
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST="content-type,custom_request_header"
+
+will extract ``content-type`` and ``custom_request_header`` from the request headers and add them as span attributes.
+
+Request header names in Niquests are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment
+variable will capture the header named ``custom-header``.
+
+Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example:
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST="Accept.*,X-.*"
+
+Would match all request headers that start with ``Accept`` and ``X-``.
+
+To capture all request headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST`` to ``".*"``.
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST=".*"
+
+The name of the added span attribute will follow the format ``http.request.header.`` where ````
+is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
+single item list containing all the header values.
+
+For example:
+``http.request.header.custom_request_header = ["", ""]``
+
+Response headers
+****************
+To capture HTTP response headers as span attributes, set the environment variable
+``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE`` to a comma delimited list of HTTP header names.
+
+For example using the environment variable,
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE="content-type,custom_response_header"
+
+will extract ``content-type`` and ``custom_response_header`` from the response headers and add them as span attributes.
+
+Response header names in Niquests are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment
+variable will capture the header named ``custom-header``.
+
+Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example:
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE="Content.*,X-.*"
+
+Would match all response headers that start with ``Content`` and ``X-``.
+
+To capture all response headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE`` to ``".*"``.
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE=".*"
+
+The name of the added span attribute will follow the format ``http.response.header.`` where ````
+is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
+list containing the header values.
+
+For example:
+``http.response.header.custom_response_header = ["", ""]``
+
+Sanitizing headers
+******************
+In order to prevent storing sensitive data such as personally identifiable information (PII), session keys, passwords,
+etc, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS``
+to a comma delimited list of HTTP header names to be sanitized.
+
+Regexes may be used, and all header names will be matched in a case-insensitive manner.
+
+For example using the environment variable,
+::
+
+ export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS=".*session.*,set-cookie"
+
+will replace the value of headers such as ``session-id`` and ``set-cookie`` with ``[REDACTED]`` in the span.
+
+Note:
+ The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change.
+
+Niquests-specific attributes
+****************************
+
+This instrumentation leverages niquests' ``conn_info`` metadata to provide
+richer telemetry than what is available with plain ``requests``:
+
+Span attributes
+ ``tls.protocol.version`` -- TLS version (e.g. ``"1.3"``), from ``conn_info.tls_version``.
+ ``tls.cipher`` -- TLS cipher suite, from ``conn_info.cipher``.
+ ``revocation_verified`` -- boolean OCSP/CRL verification status, from ``response.ocsp_verified``.
+
+Connection sub-duration histogram metrics (seconds)
+ ``http.client.connection.dns.duration`` -- from ``conn_info.resolution_latency``.
+ ``http.client.connection.tcp.duration`` -- from ``conn_info.established_latency``.
+ ``http.client.connection.tls.duration`` -- from ``conn_info.tls_handshake_latency``.
+ ``http.client.request.send.duration`` -- from ``conn_info.request_sent_latency``.
+
+Custom Duration Histogram Boundaries
+************************************
+To customize the duration histogram bucket boundaries used for HTTP client request duration metrics,
+you can provide a list of values when instrumenting:
+
+.. code:: python
+
+ import niquests
+ from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+
+ custom_boundaries = [0.0, 5.0, 10.0, 25.0, 50.0, 100.0]
+
+ NiquestsInstrumentor().instrument(
+ duration_histogram_boundaries=custom_boundaries
+ )
+
+Exclude lists
+*************
+To exclude certain URLs from being tracked, set the environment variable ``OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS``
+(or ``OTEL_PYTHON_EXCLUDED_URLS`` as fallback) with comma delimited regexes representing which URLs to exclude.
+
+For example,
+
+::
+
+ export OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS="client/.*/info,healthcheck"
+
+will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``.
+
+API
+---
+"""
+
+from __future__ import annotations
+
+import functools
+import types
+from timeit import default_timer
+from typing import Any, Callable, Collection, Optional
+from urllib.parse import urlparse
+
+from niquests import AsyncSession
+from niquests.models import PreparedRequest, Response
+from niquests.sessions import Session
+from niquests.structures import CaseInsensitiveDict
+
+from opentelemetry.instrumentation._semconv import (
+ HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
+ HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
+ _client_duration_attrs_new,
+ _client_duration_attrs_old,
+ _filter_semconv_duration_attrs,
+ _get_schema_url,
+ _OpenTelemetrySemanticConventionStability,
+ _OpenTelemetryStabilitySignalType,
+ _report_new,
+ _report_old,
+ _set_http_host_client,
+ _set_http_method,
+ _set_http_net_peer_name_client,
+ _set_http_network_protocol_version,
+ _set_http_peer_port_client,
+ _set_http_scheme,
+ _set_http_url,
+ _set_status,
+ _StabilityMode,
+)
+from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
+from opentelemetry.instrumentation.niquests.package import _instruments
+from opentelemetry.instrumentation.niquests.version import __version__
+from opentelemetry.instrumentation.utils import (
+ is_http_instrumentation_enabled,
+ suppress_http_instrumentation,
+)
+from opentelemetry.metrics import Histogram, get_meter
+from opentelemetry.propagate import inject
+from opentelemetry.semconv._incubating.attributes.net_attributes import (
+ NET_PEER_IP,
+)
+from opentelemetry.semconv._incubating.attributes.tls_attributes import (
+ TLS_CIPHER,
+ TLS_PROTOCOL_VERSION,
+)
+from opentelemetry.semconv._incubating.attributes.user_agent_attributes import (
+ USER_AGENT_SYNTHETIC_TYPE,
+)
+from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
+from opentelemetry.semconv.attributes.network_attributes import (
+ NETWORK_PEER_ADDRESS,
+ NETWORK_PEER_PORT,
+)
+from opentelemetry.semconv.attributes.user_agent_attributes import (
+ USER_AGENT_ORIGINAL,
+)
+from opentelemetry.semconv.metrics import MetricInstruments
+from opentelemetry.semconv.metrics.http_metrics import (
+ HTTP_CLIENT_REQUEST_DURATION,
+)
+from opentelemetry.trace import SpanKind, Tracer, get_tracer
+from opentelemetry.trace.span import Span
+from opentelemetry.util.http import (
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST,
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE,
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
+ ExcludeList,
+ detect_synthetic_user_agent,
+ get_custom_header_attributes,
+ get_custom_headers,
+ get_excluded_urls,
+ normalise_request_header_name,
+ normalise_response_header_name,
+ normalize_user_agent,
+ parse_excluded_urls,
+ redact_url,
+ sanitize_method,
+)
+
+_excluded_urls_from_env = get_excluded_urls("NIQUESTS")
+
+_RequestHookT = Optional[Callable[[Span, PreparedRequest], None]]
+_ResponseHookT = Optional[Callable[[Span, PreparedRequest, Response], None]]
+
+
+def _set_http_status_code_attribute(
+ span,
+ status_code,
+ metric_attributes=None,
+ sem_conv_opt_in_mode=_StabilityMode.DEFAULT,
+):
+ status_code_str = str(status_code)
+ try:
+ status_code = int(status_code)
+ except ValueError:
+ status_code = -1
+ if metric_attributes is None:
+ metric_attributes = {}
+ # When we have durations we should set metrics only once
+ # Also the decision to include status code on a histogram should
+ # not be dependent on tracing decisions.
+ _set_status(
+ span,
+ metric_attributes,
+ status_code,
+ status_code_str,
+ server_span=False,
+ sem_conv_opt_in_mode=sem_conv_opt_in_mode,
+ )
+
+
+def _extract_http_version(result: Response) -> str | None:
+ """Extract the HTTP protocol version from a niquests Response.
+
+ Niquests supports HTTP/1.1, HTTP/2, and HTTP/3.
+ Returns the version string without the ``HTTP/`` prefix, matching
+ the format expected by OpenTelemetry semantic conventions
+ (e.g. ``"1.1"``, ``"2"``, ``"3"``).
+ """
+ raw_version_map = {11: "1.1", 20: "2", 30: "3"}
+ try:
+ return raw_version_map.get(result.http_version, None)
+ except (
+ AttributeError
+ ): # Defensive: mocking utilities may omit http_version
+ return None
+
+
+def _extract_tls_version(result: Response) -> str | None:
+ """Extract the TLS protocol version from a niquests Response via conn_info.
+
+ Converts from Python's ``ssl.TLSVersion`` enum (e.g. ``TLSv1_3``) to the
+ OpenTelemetry semconv format (e.g. ``"1.3"``).
+
+ Returns ``None`` when TLS was not used or the value is unavailable.
+ """
+ if result.conn_info is None or result.conn_info.tls_version is None:
+ return None
+ name = result.conn_info.tls_version.name # e.g. "TLSv1_2", "TLSv1_3"
+ if name.startswith("TLSv"):
+ return name[4:].replace("_", ".") # "1_2" -> "1.2"
+ return None
+
+
+def _extract_tls_cipher(result: Response) -> str | None:
+ """Extract the TLS cipher suite name from a niquests Response via conn_info.
+
+ Returns ``None`` when TLS was not used or the value is unavailable.
+ """
+ if result.conn_info is None:
+ return None
+ return result.conn_info.cipher
+
+
+def _extract_ip_from_response(result: Response) -> tuple[str, int] | None:
+ """Extract destination IP and port from a niquests Response via conn_info.
+
+ Returns (ip, port) tuple or None if not available.
+ """
+ return result.conn_info.destination_address if result.conn_info else None
+
+
+def _prepare_span_and_metric_attributes(
+ request: PreparedRequest,
+ sem_conv_opt_in_mode: _StabilityMode,
+ captured_request_headers: list[str] | None,
+ sensitive_headers: list[str] | None,
+):
+ """Build span attributes and metric labels common to both sync and async paths."""
+ method = request.method
+ span_name = get_default_span_name(method)
+ url = redact_url(request.url)
+
+ span_attributes = {}
+ _set_http_method(
+ span_attributes,
+ method,
+ sanitize_method(method),
+ sem_conv_opt_in_mode,
+ )
+ _set_http_url(span_attributes, url, sem_conv_opt_in_mode)
+
+ # Check for synthetic user agent type
+ headers = (
+ request.headers
+ if request.headers is not None
+ else CaseInsensitiveDict()
+ )
+ request.headers = headers
+ user_agent_value = headers.get("User-Agent")
+ user_agent = normalize_user_agent(user_agent_value)
+ synthetic_type = detect_synthetic_user_agent(user_agent)
+ if synthetic_type:
+ span_attributes[USER_AGENT_SYNTHETIC_TYPE] = synthetic_type
+ if user_agent:
+ span_attributes[USER_AGENT_ORIGINAL] = user_agent
+ span_attributes.update(
+ get_custom_header_attributes(
+ headers,
+ captured_request_headers,
+ sensitive_headers,
+ normalise_request_header_name,
+ )
+ )
+
+ metric_labels = {}
+ _set_http_method(
+ metric_labels,
+ method,
+ sanitize_method(method),
+ sem_conv_opt_in_mode,
+ )
+
+ try:
+ parsed_url = urlparse(url)
+ if parsed_url.scheme:
+ if _report_old(sem_conv_opt_in_mode):
+ _set_http_scheme(
+ metric_labels, parsed_url.scheme, sem_conv_opt_in_mode
+ )
+ if parsed_url.hostname:
+ _set_http_host_client(
+ metric_labels, parsed_url.hostname, sem_conv_opt_in_mode
+ )
+ _set_http_net_peer_name_client(
+ metric_labels, parsed_url.hostname, sem_conv_opt_in_mode
+ )
+ if _report_new(sem_conv_opt_in_mode):
+ _set_http_host_client(
+ span_attributes,
+ parsed_url.hostname,
+ sem_conv_opt_in_mode,
+ )
+ span_attributes[NETWORK_PEER_ADDRESS] = parsed_url.hostname
+ if parsed_url.port:
+ _set_http_peer_port_client(
+ metric_labels, parsed_url.port, sem_conv_opt_in_mode
+ )
+ if _report_new(sem_conv_opt_in_mode):
+ _set_http_peer_port_client(
+ span_attributes, parsed_url.port, sem_conv_opt_in_mode
+ )
+ span_attributes[NETWORK_PEER_PORT] = parsed_url.port
+ except ValueError:
+ pass
+
+ return span_name, span_attributes, metric_labels, headers
+
+
+def _apply_conn_info_attributes(
+ span: Span,
+ result: Response,
+ sem_conv_opt_in_mode: _StabilityMode,
+):
+ """Set IP, TLS, and revocation span attributes from conn_info."""
+ ip_info = _extract_ip_from_response(result)
+ if ip_info is not None:
+ ip_addr, ip_port = ip_info
+ if _report_old(sem_conv_opt_in_mode):
+ span.set_attribute(NET_PEER_IP, ip_addr)
+ if _report_new(sem_conv_opt_in_mode):
+ span.set_attribute(NETWORK_PEER_ADDRESS, ip_addr)
+ if ip_port:
+ span.set_attribute(NETWORK_PEER_PORT, ip_port)
+
+ tls_version = _extract_tls_version(result)
+ if tls_version is not None:
+ span.set_attribute(TLS_PROTOCOL_VERSION, tls_version)
+ tls_cipher = _extract_tls_cipher(result)
+ if tls_cipher is not None:
+ span.set_attribute(TLS_CIPHER, tls_cipher)
+
+ if result.ocsp_verified is not None:
+ span.set_attribute("tls.revocation.verified", result.ocsp_verified)
+
+
+def _apply_response_attributes(
+ span: Span,
+ result: Response,
+ metric_labels: dict,
+ sem_conv_opt_in_mode: _StabilityMode,
+ captured_response_headers: list[str] | None,
+ sensitive_headers: list[str] | None,
+ response_hook: _ResponseHookT,
+ request: PreparedRequest,
+):
+ """Apply response attributes to span and metric labels."""
+ if isinstance(result, Response):
+ resp_span_attributes = {}
+
+ if not result.lazy:
+ _set_http_status_code_attribute(
+ span,
+ result.status_code,
+ metric_labels,
+ sem_conv_opt_in_mode,
+ )
+
+ version_text = _extract_http_version(result)
+
+ if version_text:
+ _set_http_network_protocol_version(
+ metric_labels, version_text, sem_conv_opt_in_mode
+ )
+ if _report_new(sem_conv_opt_in_mode):
+ _set_http_network_protocol_version(
+ resp_span_attributes,
+ version_text,
+ sem_conv_opt_in_mode,
+ )
+
+ _apply_conn_info_attributes(span, result, sem_conv_opt_in_mode)
+
+ if not result.lazy:
+ resp_span_attributes.update(
+ get_custom_header_attributes(
+ result.headers,
+ captured_response_headers,
+ sensitive_headers,
+ normalise_response_header_name,
+ )
+ )
+
+ for key, val in resp_span_attributes.items():
+ span.set_attribute(key, val)
+
+ if callable(response_hook):
+ response_hook(span, request, result)
+
+
+def _record_duration_metrics(
+ duration_histogram_old: Histogram | None,
+ duration_histogram_new: Histogram | None,
+ elapsed_time: float,
+ metric_labels: dict,
+):
+ """Record duration metrics on the appropriate histograms."""
+ if duration_histogram_old is not None:
+ duration_attrs_old = _filter_semconv_duration_attrs(
+ metric_labels,
+ _client_duration_attrs_old,
+ _client_duration_attrs_new,
+ _StabilityMode.DEFAULT,
+ )
+ duration_histogram_old.record(
+ max(round(elapsed_time * 1000), 0),
+ attributes=duration_attrs_old,
+ )
+ if duration_histogram_new is not None:
+ duration_attrs_new = _filter_semconv_duration_attrs(
+ metric_labels,
+ _client_duration_attrs_old,
+ _client_duration_attrs_new,
+ _StabilityMode.HTTP,
+ )
+ duration_histogram_new.record(
+ elapsed_time, attributes=duration_attrs_new
+ )
+
+
+# Metric names for niquests-specific connection sub-duration histograms.
+# These are not part of the OTel HTTP semantic conventions; they are
+# custom metrics that leverage niquests' conn_info timing data.
+# NOTE: These use the ``http.client.`` prefix for discoverability. If OTel
+# semconv later defines metrics with these exact names, this instrumentation
+# must be updated to align with the official definitions.
+_CONN_METRIC_DNS_RESOLUTION = "http.client.connection.dns.duration"
+_CONN_METRIC_TCP_ESTABLISHMENT = "http.client.connection.tcp.duration"
+_CONN_METRIC_TLS_HANDSHAKE = "http.client.connection.tls.duration"
+_CONN_METRIC_REQUEST_SEND = "http.client.request.send.duration"
+
+# Maps conn_info attribute names to histogram metric names.
+_CONN_INFO_LATENCY_FIELDS = {
+ "resolution_latency": _CONN_METRIC_DNS_RESOLUTION,
+ "established_latency": _CONN_METRIC_TCP_ESTABLISHMENT,
+ "tls_handshake_latency": _CONN_METRIC_TLS_HANDSHAKE,
+ "request_sent_latency": _CONN_METRIC_REQUEST_SEND,
+}
+
+# Descriptions for each connection sub-duration histogram.
+_CONN_HISTOGRAM_DESCRIPTIONS = {
+ _CONN_METRIC_DNS_RESOLUTION: "Duration of DNS resolution for HTTP client requests.",
+ _CONN_METRIC_TCP_ESTABLISHMENT: "Duration of TCP connection establishment for HTTP client requests.",
+ _CONN_METRIC_TLS_HANDSHAKE: "Duration of TLS handshake for HTTP client requests.",
+ _CONN_METRIC_REQUEST_SEND: "Duration of sending the HTTP request through the socket.",
+}
+
+
+def _record_connection_metrics(
+ connection_histograms: dict[str, Histogram] | None,
+ result: Response | None,
+ metric_labels: dict,
+):
+ """Record connection sub-duration metrics from niquests conn_info.
+
+ Each conn_info latency field is a ``datetime.timedelta``; we convert to
+ seconds (float) for the histograms. Fields that are ``None``
+ (unavailable) or zero-duration (the phase did not occur, e.g. DNS
+ resolution on a reused connection) are silently skipped to avoid
+ polluting the histogram with meaningless data points.
+ """
+ if connection_histograms is None or result is None:
+ return
+ if not isinstance(result, Response) or result.lazy is not False:
+ return
+ conn_info = result.conn_info
+ if conn_info is None:
+ return
+
+ # Use the same metric labels as the main duration histograms.
+ # Filter to new-semconv attributes since these are new custom metrics.
+ attrs = _filter_semconv_duration_attrs(
+ metric_labels,
+ _client_duration_attrs_old,
+ _client_duration_attrs_new,
+ _StabilityMode.HTTP,
+ )
+
+ for field, metric_name in _CONN_INFO_LATENCY_FIELDS.items():
+ histogram = connection_histograms.get(metric_name)
+ if histogram is None:
+ continue
+ latency = getattr(conn_info, field, None)
+ # Skip None (unavailable) and zero-duration (phase did not occur,
+ # e.g. DNS resolution on a reused connection).
+ if latency is not None and latency.total_seconds() > 0:
+ histogram.record(latency.total_seconds(), attributes=attrs)
+
+
+# pylint: disable=unused-argument
+# pylint: disable=R0915
+def _instrument(
+ tracer: Tracer,
+ duration_histogram_old: Histogram,
+ duration_histogram_new: Histogram,
+ request_hook: _RequestHookT = None,
+ response_hook: _ResponseHookT = None,
+ excluded_urls: ExcludeList | None = None,
+ sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT,
+ captured_request_headers: list[str] | None = None,
+ captured_response_headers: list[str] | None = None,
+ sensitive_headers: list[str] | None = None,
+ connection_histograms: dict[str, Histogram] | None = None,
+):
+ """Enables tracing of all niquests calls that go through
+ :code:`niquests.sessions.Session.send` and
+ :code:`niquests.async_session.AsyncSession.send`."""
+
+ wrapped_send = Session.send
+
+ # pylint: disable-msg=too-many-locals,too-many-branches
+ @functools.wraps(wrapped_send)
+ def instrumented_send(
+ self: Session, request: PreparedRequest, **kwargs: Any
+ ):
+ if excluded_urls and excluded_urls.url_disabled(request.url):
+ return wrapped_send(self, request, **kwargs)
+
+ if not is_http_instrumentation_enabled():
+ return wrapped_send(self, request, **kwargs)
+
+ span_name, span_attributes, metric_labels, headers = (
+ _prepare_span_and_metric_attributes(
+ request,
+ sem_conv_opt_in_mode,
+ captured_request_headers,
+ sensitive_headers,
+ )
+ )
+
+ with tracer.start_as_current_span(
+ span_name, kind=SpanKind.CLIENT, attributes=span_attributes
+ ) as span:
+ exception = None
+ if callable(request_hook):
+ request_hook(span, request)
+
+ inject(headers)
+
+ with suppress_http_instrumentation():
+ start_time = default_timer()
+ try:
+ result = wrapped_send(
+ self, request, **kwargs
+ ) # *** PROCEED
+ except Exception as exc: # pylint: disable=W0703
+ exception = exc
+ result = getattr(exc, "response", None)
+ finally:
+ elapsed_time = max(default_timer() - start_time, 0)
+
+ _apply_response_attributes(
+ span,
+ result,
+ metric_labels,
+ sem_conv_opt_in_mode,
+ captured_response_headers,
+ sensitive_headers,
+ response_hook,
+ request,
+ )
+
+ if exception is not None and _report_new(sem_conv_opt_in_mode):
+ span.set_attribute(ERROR_TYPE, type(exception).__qualname__)
+ metric_labels[ERROR_TYPE] = type(exception).__qualname__
+
+ _record_duration_metrics(
+ duration_histogram_old,
+ duration_histogram_new,
+ elapsed_time,
+ metric_labels,
+ )
+
+ _record_connection_metrics(
+ connection_histograms,
+ result,
+ metric_labels,
+ )
+
+ if exception is not None:
+ raise exception.with_traceback(exception.__traceback__)
+
+ return result
+
+ instrumented_send.opentelemetry_instrumentation_niquests_applied = True
+ Session.send = instrumented_send
+
+ # Also instrument AsyncSession if available
+ _instrument_async(
+ tracer,
+ duration_histogram_old,
+ duration_histogram_new,
+ request_hook=request_hook,
+ response_hook=response_hook,
+ excluded_urls=excluded_urls,
+ sem_conv_opt_in_mode=sem_conv_opt_in_mode,
+ captured_request_headers=captured_request_headers,
+ captured_response_headers=captured_response_headers,
+ sensitive_headers=sensitive_headers,
+ connection_histograms=connection_histograms,
+ )
+
+
+def _instrument_async(
+ tracer: Tracer,
+ duration_histogram_old: Histogram,
+ duration_histogram_new: Histogram,
+ request_hook: _RequestHookT = None,
+ response_hook: _ResponseHookT = None,
+ excluded_urls: ExcludeList | None = None,
+ sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT,
+ captured_request_headers: list[str] | None = None,
+ captured_response_headers: list[str] | None = None,
+ sensitive_headers: list[str] | None = None,
+ connection_histograms: dict[str, Histogram] | None = None,
+):
+ """Instruments the AsyncSession.send method."""
+ wrapped_async_send = AsyncSession.send
+
+ @functools.wraps(wrapped_async_send)
+ async def instrumented_async_send(
+ self, request: PreparedRequest, **kwargs: Any
+ ):
+ if excluded_urls and excluded_urls.url_disabled(request.url):
+ return await wrapped_async_send(self, request, **kwargs)
+
+ if not is_http_instrumentation_enabled():
+ return await wrapped_async_send(self, request, **kwargs)
+
+ span_name, span_attributes, metric_labels, headers = (
+ _prepare_span_and_metric_attributes(
+ request,
+ sem_conv_opt_in_mode,
+ captured_request_headers,
+ sensitive_headers,
+ )
+ )
+
+ with tracer.start_as_current_span(
+ span_name, kind=SpanKind.CLIENT, attributes=span_attributes
+ ) as span:
+ exception = None
+ if callable(request_hook):
+ request_hook(span, request)
+
+ inject(headers)
+
+ with suppress_http_instrumentation():
+ start_time = default_timer()
+ try:
+ result = await wrapped_async_send(
+ self, request, **kwargs
+ ) # *** PROCEED
+ except Exception as exc: # pylint: disable=W0703
+ exception = exc
+ result = getattr(exc, "response", None)
+ finally:
+ elapsed_time = max(default_timer() - start_time, 0)
+
+ _apply_response_attributes(
+ span,
+ result,
+ metric_labels,
+ sem_conv_opt_in_mode,
+ captured_response_headers,
+ sensitive_headers,
+ response_hook,
+ request,
+ )
+
+ if exception is not None and _report_new(sem_conv_opt_in_mode):
+ span.set_attribute(ERROR_TYPE, type(exception).__qualname__)
+ metric_labels[ERROR_TYPE] = type(exception).__qualname__
+
+ _record_duration_metrics(
+ duration_histogram_old,
+ duration_histogram_new,
+ elapsed_time,
+ metric_labels,
+ )
+
+ _record_connection_metrics(
+ connection_histograms,
+ result,
+ metric_labels,
+ )
+
+ if exception is not None:
+ raise exception.with_traceback(exception.__traceback__)
+
+ return result
+
+ instrumented_async_send.opentelemetry_instrumentation_niquests_applied = (
+ True
+ )
+ AsyncSession.send = instrumented_async_send
+
+
+def _uninstrument():
+ """Disables instrumentation of :code:`niquests` through this module.
+
+ Note that this only works if no other module also patches niquests."""
+ _uninstrument_from(Session)
+ _uninstrument_from(AsyncSession)
+
+
+def _uninstrument_from(instr_root, restore_as_bound_func: bool = False):
+ instr_func = getattr(instr_root, "send")
+ if not getattr(
+ instr_func,
+ "opentelemetry_instrumentation_niquests_applied",
+ False,
+ ):
+ return
+
+ original = instr_func.__wrapped__ # pylint:disable=no-member
+ if restore_as_bound_func:
+ original = types.MethodType(original, instr_root)
+ setattr(instr_root, "send", original)
+
+
+def get_default_span_name(method: str) -> str:
+ """
+ Default implementation for name_callback, returns HTTP {method_name}.
+ https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/http/#name
+
+ Args:
+ method: string representing HTTP method
+ Returns:
+ span name
+ """
+ method = sanitize_method(method.strip())
+ if method == "_OTHER":
+ return "HTTP"
+ return method
+
+
+class NiquestsInstrumentor(BaseInstrumentor):
+ """An instrumentor for niquests
+ See `BaseInstrumentor`
+ """
+
+ def instrumentation_dependencies(self) -> Collection[str]:
+ return _instruments
+
+ def _instrument(self, **kwargs: Any):
+ """Instruments niquests module
+
+ Args:
+ **kwargs: Optional arguments
+ ``tracer_provider``: a TracerProvider, defaults to global
+ ``request_hook``: An optional callback that is invoked right after a span is created.
+ ``response_hook``: An optional callback which is invoked right before the span is finished processing a response.
+ ``excluded_urls``: A string containing a comma-delimited list of regexes used to exclude URLs from tracking
+ ``duration_histogram_boundaries``: A list of float values representing the explicit bucket boundaries for the duration histogram.
+ """
+ semconv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
+ _OpenTelemetryStabilitySignalType.HTTP,
+ )
+ schema_url = _get_schema_url(semconv_opt_in_mode)
+ tracer = get_tracer(
+ __name__,
+ __version__,
+ kwargs.get("tracer_provider"),
+ schema_url=schema_url,
+ )
+ excluded_urls = kwargs.get("excluded_urls")
+ duration_histogram_boundaries = kwargs.get(
+ "duration_histogram_boundaries"
+ )
+ meter = get_meter(
+ __name__,
+ __version__,
+ kwargs.get("meter_provider"),
+ schema_url=schema_url,
+ )
+ duration_histogram_old = None
+ if _report_old(semconv_opt_in_mode):
+ duration_histogram_old = meter.create_histogram(
+ name=MetricInstruments.HTTP_CLIENT_DURATION,
+ unit="ms",
+ description="measures the duration of the outbound HTTP request",
+ explicit_bucket_boundaries_advisory=duration_histogram_boundaries
+ or HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
+ )
+ duration_histogram_new = None
+ if _report_new(semconv_opt_in_mode):
+ duration_histogram_new = meter.create_histogram(
+ name=HTTP_CLIENT_REQUEST_DURATION,
+ unit="s",
+ description="Duration of HTTP client requests.",
+ explicit_bucket_boundaries_advisory=duration_histogram_boundaries
+ or HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
+ )
+
+ # Niquests-specific connection sub-duration histograms.
+ # These leverage niquests' conn_info timing data to give
+ # visibility into DNS, TCP, TLS, and request-send phases.
+ connection_histograms = {
+ name: meter.create_histogram(name=name, unit="s", description=desc)
+ for name, desc in _CONN_HISTOGRAM_DESCRIPTIONS.items()
+ }
+
+ _instrument(
+ tracer,
+ duration_histogram_old,
+ duration_histogram_new,
+ request_hook=kwargs.get("request_hook"),
+ response_hook=kwargs.get("response_hook"),
+ excluded_urls=(
+ _excluded_urls_from_env
+ if excluded_urls is None
+ else parse_excluded_urls(excluded_urls)
+ ),
+ sem_conv_opt_in_mode=semconv_opt_in_mode,
+ captured_request_headers=get_custom_headers(
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST
+ ),
+ captured_response_headers=get_custom_headers(
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE
+ ),
+ sensitive_headers=get_custom_headers(
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
+ ),
+ connection_histograms=connection_histograms,
+ )
+
+ def _uninstrument(self, **kwargs: Any):
+ _uninstrument()
+
+ @staticmethod
+ def uninstrument_session(session: Session):
+ """Disables instrumentation on the session object."""
+ _uninstrument_from(session, restore_as_bound_func=True)
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/package.py b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/package.py
new file mode 100644
index 0000000000..82c67be991
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/package.py
@@ -0,0 +1,20 @@
+# 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 = ("niquests >= 3.0, < 4",)
+
+_supports_metrics = True
+
+_semconv_status = "migration"
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/py.typed b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/py.typed
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/version.py b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/version.py
new file mode 100644
index 0000000000..a07bc2663e
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/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.63b0.dev"
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt
new file mode 100644
index 0000000000..887d9c5b04
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt
@@ -0,0 +1,16 @@
+asgiref==3.8.1
+Deprecated==1.2.14
+iniconfig==2.0.0
+niquests
+packaging==24.0
+pluggy==1.6.0
+py-cpuinfo==9.0.0
+pytest==7.4.4
+responses
+tomli==2.0.1
+typing_extensions==4.12.2
+wrapt==1.16.0
+zipp==3.19.2
+-e opentelemetry-instrumentation
+-e util/opentelemetry-util-http
+-e instrumentation/opentelemetry-instrumentation-niquests
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/conftest.py
new file mode 100644
index 0000000000..fc24d22c89
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/conftest.py
@@ -0,0 +1,186 @@
+# 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.
+
+"""conftest.py – make the ``responses`` library work with Niquests.
+
+The ``responses`` library is designed for ``requests``. Since Niquests is a
+drop-in replacement we can redirect the relevant ``sys.modules`` entries so
+that ``responses`` patches Niquests adapters instead.
+
+The async extension (``NiquestsMock``) additionally patches
+``AsyncHTTPAdapter.send`` so that ``await session.get(...)`` also hits the
+mock registry.
+
+See https://niquests.readthedocs.io/en/latest/community/extensions.html#responses
+"""
+
+from __future__ import annotations
+
+import typing
+from sys import modules
+from unittest import mock as std_mock
+
+import niquests
+from niquests.packages import urllib3
+
+# responses is tied to Requests
+# and Niquests is entirely compatible with it.
+# we can fool it without effort.
+modules["requests"] = niquests
+modules["requests.adapters"] = niquests.adapters
+modules["requests.models"] = niquests.models
+modules["requests.exceptions"] = niquests.exceptions
+modules["requests.packages.urllib3"] = urllib3
+
+# make 'responses' mock both sync and async
+# 'Requests' ever only supported sync
+# Fortunately interfaces are mirrored in 'Niquests'
+import responses # noqa: E402 pylint: disable=wrong-import-position
+
+
+class NiquestsMock(responses.RequestsMock):
+ """Asynchronous support for responses"""
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(
+ *args,
+ target="niquests.adapters.HTTPAdapter.send",
+ **kwargs,
+ )
+
+ self._patcher_async = None
+
+ def unbound_on_async_send(self):
+ async def send(
+ adapter: "niquests.adapters.AsyncHTTPAdapter",
+ request: "niquests.PreparedRequest",
+ *args: typing.Any,
+ **kwargs: typing.Any,
+ ) -> "niquests.Response":
+ if args:
+ try:
+ kwargs["stream"] = args[0]
+ kwargs["timeout"] = args[1]
+ kwargs["verify"] = args[2]
+ kwargs["cert"] = args[3]
+ kwargs["proxies"] = args[4]
+ except IndexError:
+ pass
+
+ resp = self._on_request(adapter, request, **kwargs)
+
+ if kwargs.get("stream"):
+ return resp
+
+ resp.__class__ = niquests.Response
+ return resp
+
+ return send
+
+ def unbound_on_send(self):
+ def send(
+ adapter: "niquests.adapters.HTTPAdapter",
+ request: "niquests.PreparedRequest",
+ *args: typing.Any,
+ **kwargs: typing.Any,
+ ) -> "niquests.Response":
+ if args:
+ try:
+ kwargs["stream"] = args[0]
+ kwargs["timeout"] = args[1]
+ kwargs["verify"] = args[2]
+ kwargs["cert"] = args[3]
+ kwargs["proxies"] = args[4]
+ except IndexError:
+ pass
+
+ return self._on_request(adapter, request, **kwargs)
+
+ return send
+
+ def start(self) -> None:
+ if self._patcher:
+ return
+
+ self._patcher = std_mock.patch(
+ target=self.target, new=self.unbound_on_send()
+ )
+ self._patcher_async = std_mock.patch(
+ target=self.target.replace("HTTPAdapter", "AsyncHTTPAdapter"),
+ new=self.unbound_on_async_send(),
+ )
+
+ self._patcher.start()
+ self._patcher_async.start()
+
+ def stop(self, allow_assert: bool = True) -> None:
+ if self._patcher:
+ self._patcher.stop()
+ self._patcher_async.stop()
+
+ self._patcher = None
+ self._patcher_async = None
+
+ if not self.assert_all_requests_are_fired:
+ return
+
+ if not allow_assert:
+ return
+
+ not_called = [m for m in self.registered() if m.call_count == 0]
+ if not_called:
+ raise AssertionError(
+ f"Not all requests have been executed {[(match.method, match.url) for match in not_called]!r}"
+ )
+
+
+mock = _default_mock = NiquestsMock(assert_all_requests_are_fired=False)
+
+setattr(responses, "mock", mock)
+setattr(responses, "_default_mock", _default_mock)
+
+for kw in [
+ "activate",
+ "add",
+ "_add_from_file",
+ "add_callback",
+ "add_passthru",
+ "assert_call_count",
+ "calls",
+ "delete",
+ "DELETE",
+ "get",
+ "GET",
+ "head",
+ "HEAD",
+ "options",
+ "OPTIONS",
+ "patch",
+ "PATCH",
+ "post",
+ "POST",
+ "put",
+ "PUT",
+ "registered",
+ "remove",
+ "replace",
+ "reset",
+ "response_callback",
+ "start",
+ "stop",
+ "upsert",
+]:
+ if not hasattr(responses, kw):
+ continue
+ setattr(responses, kw, getattr(mock, kw))
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_async.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_async.py
new file mode 100644
index 0000000000..b374e84018
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_async.py
@@ -0,0 +1,1170 @@
+# 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.
+
+# pylint: disable=too-many-lines
+
+"""Tests for async instrumentation of niquests (AsyncSession).
+
+Follows the httpx pattern: a ``BaseTestCases`` wrapper class contains an
+abstract ``BaseManualTest`` that is concretely subclassed by
+``TestAsyncIntegration``. Async calls are bridged into synchronous
+``unittest.TestCase`` methods via ``asyncio.run()``.
+"""
+
+import abc
+import asyncio
+import re
+import ssl
+from datetime import timedelta
+from unittest import mock
+
+import niquests
+import responses
+from niquests.adapters import AsyncBaseAdapter
+from niquests.async_session import AsyncSession
+from niquests.models import ConnectionInfo, Response
+
+import opentelemetry.instrumentation.niquests
+from opentelemetry import trace
+from opentelemetry.instrumentation._semconv import (
+ OTEL_SEMCONV_STABILITY_OPT_IN,
+ _get_schema_url,
+ _OpenTelemetrySemanticConventionStability,
+ _StabilityMode,
+)
+from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+from opentelemetry.instrumentation.utils import (
+ suppress_http_instrumentation,
+ suppress_instrumentation,
+)
+from opentelemetry.propagate import get_global_textmap, set_global_textmap
+from opentelemetry.sdk import resources
+from opentelemetry.semconv._incubating.attributes.http_attributes import (
+ HTTP_FLAVOR,
+ HTTP_HOST,
+ HTTP_METHOD,
+ HTTP_SCHEME,
+ HTTP_STATUS_CODE,
+ HTTP_URL,
+)
+from opentelemetry.semconv._incubating.attributes.net_attributes import (
+ NET_PEER_NAME,
+ NET_PEER_PORT,
+)
+from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
+from opentelemetry.semconv.attributes.http_attributes import (
+ HTTP_REQUEST_METHOD,
+ HTTP_REQUEST_METHOD_ORIGINAL,
+ HTTP_RESPONSE_STATUS_CODE,
+)
+from opentelemetry.semconv.attributes.network_attributes import (
+ NETWORK_PEER_ADDRESS,
+ NETWORK_PEER_PORT,
+ NETWORK_PROTOCOL_VERSION,
+)
+from opentelemetry.semconv.attributes.server_attributes import (
+ SERVER_ADDRESS,
+ SERVER_PORT,
+)
+from opentelemetry.semconv.attributes.url_attributes import URL_FULL
+from opentelemetry.semconv.attributes.user_agent_attributes import (
+ USER_AGENT_ORIGINAL,
+)
+from opentelemetry.test.mock_textmap import MockTextMapPropagator
+from opentelemetry.test.test_base import TestBase
+from opentelemetry.trace import StatusCode
+from opentelemetry.util.http import (
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST,
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE,
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
+ get_excluded_urls,
+)
+
+_NIQUESTS_USER_AGENT = f"niquests/{niquests.__version__}"
+
+
+def _async_call(coro):
+ """Bridge an awaitable into a synchronous unittest method."""
+ return asyncio.run(coro)
+
+
+# Using this wrapper class to have a base class for the tests while also not
+# angering pylint or mypy when calling methods not in the class when only
+# subclassing abc.ABC.
+class BaseTestCases:
+ class BaseManualTest(TestBase, metaclass=abc.ABCMeta):
+ # pylint: disable=no-member
+ # pylint: disable=too-many-public-methods
+
+ URL = "http://mock/status/200"
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ super().setUp()
+
+ test_name = ""
+ if hasattr(self, "_testMethodName"):
+ test_name = self._testMethodName
+ sem_conv_mode = "default"
+ if "new_semconv" in test_name:
+ sem_conv_mode = "http"
+ elif "both_semconv" in test_name:
+ sem_conv_mode = "http/dup"
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {
+ "OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS": "http://localhost/env_excluded_arg/123,env_excluded_noarg",
+ OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode,
+ },
+ )
+
+ _OpenTelemetrySemanticConventionStability._initialized = False
+
+ self.env_patch.start()
+
+ self.exclude_patch = mock.patch(
+ "opentelemetry.instrumentation.niquests._excluded_urls_from_env",
+ get_excluded_urls("NIQUESTS"),
+ )
+ self.exclude_patch.start()
+
+ NiquestsInstrumentor().instrument()
+ responses.start()
+ responses.add(responses.GET, self.URL, body="Hello!", status=200)
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ self.exclude_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+ responses.stop()
+ responses.reset()
+
+ def assert_span(self, exporter=None, num_spans=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: dict = 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, "GET")
+
+ self.assertEqual(
+ span.instrumentation_scope.schema_url,
+ _get_schema_url(_StabilityMode.DEFAULT),
+ )
+
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 200,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+
+ self.assertIs(span.status.status_code, trace.StatusCode.UNSET)
+
+ self.assertEqualSpanInstrumentationScope(
+ span, opentelemetry.instrumentation.niquests
+ )
+
+ def test_basic_new_semconv(self):
+ url_with_port = "http://mock:80/status/200"
+ responses.add(
+ responses.GET, url_with_port, status=200, body="Hello!"
+ )
+ result = self.perform_request(url_with_port)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "GET")
+
+ self.assertEqual(
+ span.instrumentation_scope.schema_url,
+ _get_schema_url(_StabilityMode.HTTP),
+ )
+ expected = {
+ HTTP_REQUEST_METHOD: "GET",
+ URL_FULL: url_with_port,
+ SERVER_ADDRESS: "mock",
+ NETWORK_PEER_ADDRESS: "mock",
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_PORT: 80,
+ NETWORK_PEER_PORT: 80,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ }
+ # responses mock does not provide raw.version, so
+ # NETWORK_PROTOCOL_VERSION is only present on real connections.
+ if NETWORK_PROTOCOL_VERSION in span.attributes:
+ expected[NETWORK_PROTOCOL_VERSION] = span.attributes[
+ NETWORK_PROTOCOL_VERSION
+ ]
+ self.assertEqual(span.attributes, expected)
+
+ self.assertIs(span.status.status_code, trace.StatusCode.UNSET)
+
+ self.assertEqualSpanInstrumentationScope(
+ span, opentelemetry.instrumentation.niquests
+ )
+
+ def test_basic_both_semconv(self):
+ url_with_port = "http://mock:80/status/200"
+ responses.add(
+ responses.GET, url_with_port, status=200, body="Hello!"
+ )
+ result = self.perform_request(url_with_port)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "GET")
+
+ self.assertEqual(
+ span.instrumentation_scope.schema_url,
+ _get_schema_url(_StabilityMode.HTTP),
+ )
+ expected = {
+ HTTP_METHOD: "GET",
+ HTTP_REQUEST_METHOD: "GET",
+ HTTP_URL: url_with_port,
+ URL_FULL: url_with_port,
+ HTTP_HOST: "mock",
+ SERVER_ADDRESS: "mock",
+ NETWORK_PEER_ADDRESS: "mock",
+ NET_PEER_PORT: 80,
+ HTTP_STATUS_CODE: 200,
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_PORT: 80,
+ NETWORK_PEER_PORT: 80,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ }
+ # responses mock does not provide raw.version, so
+ # protocol version attributes are only present on real connections.
+ if HTTP_FLAVOR in span.attributes:
+ expected[HTTP_FLAVOR] = span.attributes[HTTP_FLAVOR]
+ if NETWORK_PROTOCOL_VERSION in span.attributes:
+ expected[NETWORK_PROTOCOL_VERSION] = span.attributes[
+ NETWORK_PROTOCOL_VERSION
+ ]
+ self.assertEqual(span.attributes, expected)
+
+ self.assertIs(span.status.status_code, trace.StatusCode.UNSET)
+
+ self.assertEqualSpanInstrumentationScope(
+ span, opentelemetry.instrumentation.niquests
+ )
+
+ def test_not_found_basic(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+ result = self.perform_request(url_404)
+ self.assertEqual(result.status_code, 404)
+
+ span = self.assert_span()
+
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404)
+
+ self.assertIs(
+ span.status.status_code,
+ trace.StatusCode.ERROR,
+ )
+
+ def test_not_found_basic_new_semconv(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+ result = self.perform_request(url_404)
+ self.assertEqual(result.status_code, 404)
+
+ span = self.assert_span()
+
+ self.assertEqual(
+ span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404
+ )
+ self.assertEqual(span.attributes.get(ERROR_TYPE), "404")
+
+ self.assertIs(
+ span.status.status_code,
+ trace.StatusCode.ERROR,
+ )
+
+ def test_not_found_basic_both_semconv(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+ result = self.perform_request(url_404)
+ self.assertEqual(result.status_code, 404)
+
+ span = self.assert_span()
+
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404)
+ self.assertEqual(
+ span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404
+ )
+ self.assertEqual(span.attributes.get(ERROR_TYPE), "404")
+
+ self.assertIs(
+ span.status.status_code,
+ trace.StatusCode.ERROR,
+ )
+
+ def test_nonstandard_http_method(self):
+ responses.add("NONSTANDARD", self.URL, status=405)
+ result = self.perform_request(self.URL, method="NONSTANDARD")
+ self.assertEqual(result.status_code, 405)
+ span = self.assert_span()
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "HTTP")
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "_OTHER",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 405,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertIs(span.status.status_code, trace.StatusCode.ERROR)
+
+ def test_nonstandard_http_method_new_semconv(self):
+ responses.add("NONSTANDARD", self.URL, status=405)
+ result = self.perform_request(self.URL, method="NONSTANDARD")
+ self.assertEqual(result.status_code, 405)
+ span = self.assert_span()
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "HTTP")
+ expected = {
+ HTTP_REQUEST_METHOD: "_OTHER",
+ URL_FULL: self.URL,
+ SERVER_ADDRESS: "mock",
+ NETWORK_PEER_ADDRESS: "mock",
+ HTTP_RESPONSE_STATUS_CODE: 405,
+ ERROR_TYPE: "405",
+ HTTP_REQUEST_METHOD_ORIGINAL: "NONSTANDARD",
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ }
+ if NETWORK_PROTOCOL_VERSION in span.attributes:
+ expected[NETWORK_PROTOCOL_VERSION] = span.attributes[
+ NETWORK_PROTOCOL_VERSION
+ ]
+ self.assertEqual(span.attributes, expected)
+ self.assertIs(span.status.status_code, trace.StatusCode.ERROR)
+
+ def test_excluded_urls_from_env(self):
+ url = "http://localhost/env_excluded_arg/123"
+ responses.add(responses.GET, url, status=200)
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ self.perform_request(self.URL)
+ self.perform_request(url)
+
+ self.assert_span(num_spans=1)
+
+ def test_name_callback_default(self):
+ def name_callback(method, url):
+ return 123
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(name_callback=name_callback)
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertEqual(span.name, "GET")
+
+ def test_hooks(self):
+ def request_hook(span, request_obj):
+ span.update_name("name set from hook")
+
+ def response_hook(span, request_obj, response):
+ span.set_attribute("response_hook_attr", "value")
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(
+ request_hook=request_hook, response_hook=response_hook
+ )
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertEqual(span.name, "name set from hook")
+ self.assertEqual(span.attributes["response_hook_attr"], "value")
+
+ def test_excluded_urls_explicit(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(excluded_urls=".*/404")
+ self.perform_request(self.URL)
+ self.perform_request(url_404)
+
+ self.assert_span(num_spans=1)
+
+ def test_uninstrument(self):
+ NiquestsInstrumentor().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
+ NiquestsInstrumentor().instrument()
+
+ def test_suppress_instrumentation(self):
+ with suppress_instrumentation():
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ self.assert_span(num_spans=0)
+
+ def test_suppress_http_instrumentation(self):
+ with suppress_http_instrumentation():
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ self.assert_span(num_spans=0)
+
+ def test_not_recording(self):
+ with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span:
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(
+ tracer_provider=trace.NoOpTracerProvider()
+ )
+ mock_span.is_recording.return_value = False
+ result = self.perform_request(self.URL)
+ 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_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(responses.calls[-1].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_response_hook(self):
+ NiquestsInstrumentor().uninstrument()
+
+ def response_hook(
+ span,
+ request: niquests.PreparedRequest,
+ response: niquests.Response,
+ ):
+ span.set_attribute(
+ "http.response.body",
+ response.content.decode("utf-8"),
+ )
+
+ NiquestsInstrumentor().instrument(
+ tracer_provider=self.tracer_provider,
+ response_hook=response_hook,
+ )
+
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 200,
+ "http.response.body": "Hello!",
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+
+ def test_custom_tracer_provider(self):
+ resource = resources.Resource.create({})
+ result = self.create_tracer_provider(resource=resource)
+ tracer_provider, exporter = result
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(tracer_provider=tracer_provider)
+
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span(exporter=exporter)
+ self.assertIs(span.resource, resource)
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Header",
+ },
+ )
+ def test_custom_request_headers_captured(self):
+ """Test that specified request headers are captured as span attributes."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+
+ responses.add(responses.GET, self.URL, body="Hello!")
+ result = self.perform_request(
+ self.URL,
+ headers={"X-Custom-Header": "custom-value"},
+ )
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.x_custom_header"],
+ ("custom-value",),
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Header",
+ },
+ )
+ def test_custom_response_headers_captured(self):
+ """Test that specified response headers are captured as span attributes."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+
+ responses.replace(
+ responses.GET,
+ self.URL,
+ body="Hello!",
+ headers={"X-Custom-Header": "custom-value"},
+ )
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.response.header.x_custom_header"],
+ ("custom-value",),
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "Authorization",
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: "Authorization",
+ },
+ )
+ def test_sensitive_headers_sanitized(self):
+ """Test that sensitive header values are redacted."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+
+ responses.add(responses.GET, self.URL, body="Hello!")
+ result = self.perform_request(
+ self.URL,
+ headers={"Authorization": "Bearer secret-token"},
+ )
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.authorization"],
+ ("[REDACTED]",),
+ )
+
+
+class InvalidResponseObjectException(Exception):
+ def __init__(self):
+ super().__init__()
+ self.response = {}
+
+
+class TestAsyncIntegration(BaseTestCases.BaseManualTest):
+ """Async tests for ``AsyncSession`` – mirrors the httpx pattern."""
+
+ def perform_request(
+ self,
+ url: str,
+ method: str = "GET",
+ headers: dict = None,
+ ):
+ async def _perform_request():
+ async with AsyncSession() as session:
+ return await session.request(method, url, headers=headers)
+
+ return _async_call(_perform_request())
+
+ @mock.patch(
+ "niquests.adapters.AsyncHTTPAdapter.send",
+ side_effect=niquests.RequestException,
+ )
+ def test_exception_without_response(self, *_, **__):
+ with self.assertRaises(niquests.RequestException):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.AsyncHTTPAdapter.send",
+ side_effect=niquests.RequestException,
+ )
+ def test_exception_new_semconv(self, *_, **__):
+ url_with_port = "http://mock:80/status/200"
+ responses.add(responses.GET, url_with_port, status=200, body="Hello!")
+ with self.assertRaises(niquests.RequestException):
+ self.perform_request(url_with_port)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_REQUEST_METHOD: "GET",
+ URL_FULL: url_with_port,
+ SERVER_ADDRESS: "mock",
+ SERVER_PORT: 80,
+ NETWORK_PEER_PORT: 80,
+ NETWORK_PEER_ADDRESS: "mock",
+ ERROR_TYPE: "RequestException",
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.AsyncHTTPAdapter.send",
+ side_effect=niquests.Timeout,
+ )
+ def test_timeout_exception(self, *_, **__):
+ with self.assertRaises(Exception):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.AsyncHTTPAdapter.send",
+ side_effect=InvalidResponseObjectException,
+ )
+ def test_exception_without_proper_response_type(self, *_, **__):
+ with self.assertRaises(InvalidResponseObjectException):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ mocked_response = niquests.Response()
+ mocked_response.status_code = 500
+ mocked_response.reason = "Internal Server Error"
+
+ @mock.patch(
+ "niquests.adapters.AsyncHTTPAdapter.send",
+ side_effect=niquests.RequestException(response=mocked_response),
+ )
+ def test_exception_with_response(self, *_, **__):
+ with self.assertRaises(niquests.RequestException):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 500,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.AsyncHTTPAdapter.send", side_effect=Exception
+ )
+ def test_basic_exception(self, *_, **__):
+ with self.assertRaises(Exception):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ def test_remove_sensitive_params(self):
+ new_url = (
+ "http://username:password@mock/status/200?AWSAccessKeyId=secret"
+ )
+ responses.add(
+ responses.GET,
+ re.compile(r"http://.*mock/status/200"),
+ body="Hello!",
+ status=200,
+ )
+ self.perform_request(new_url)
+ span = self.assert_span()
+
+ self.assertEqual(
+ span.attributes[HTTP_URL],
+ "http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED",
+ )
+
+ @mock.patch.dict("os.environ", {})
+ def test_custom_headers_not_captured_when_not_configured(self):
+ """Test that headers are not captured when env vars are not set."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ responses.add(responses.GET, self.URL, body="Hello!")
+ result = self.perform_request(
+ self.URL,
+ headers={"X-Request-Header": "request-value"},
+ )
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertNotIn(
+ "http.request.header.x_request_header", span.attributes
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Request-.*",
+ },
+ )
+ def test_custom_headers_with_regex(self):
+ """Test that header capture works with regex patterns."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ responses.add(responses.GET, self.URL, body="Hello!")
+ result = self.perform_request(
+ self.URL,
+ headers={
+ "X-Custom-Request-One": "value-one",
+ "X-Custom-Request-Two": "value-two",
+ "X-Other-Request-Header": "other-value",
+ },
+ )
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.x_custom_request_one"],
+ ("value-one",),
+ )
+ self.assertEqual(
+ span.attributes["http.request.header.x_custom_request_two"],
+ ("value-two",),
+ )
+ self.assertNotIn(
+ "http.request.header.x_other_request_header", span.attributes
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "x-request-header",
+ },
+ )
+ def test_custom_headers_case_insensitive(self):
+ """Test that header capture is case-insensitive."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ responses.add(responses.GET, self.URL, body="Hello!")
+ result = self.perform_request(
+ self.URL,
+ headers={"X-ReQuESt-HeaDER": "custom-value"},
+ )
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.x_request_header"],
+ ("custom-value",),
+ )
+
+
+SCOPE = "opentelemetry.instrumentation.niquests"
+
+
+class TestAsyncIntegrationMetric(TestBase):
+ """Metric tests for the async path (AsyncSession)."""
+
+ URL = "http://examplehost:8000/status/200"
+
+ def setUp(self):
+ super().setUp()
+ test_name = ""
+ if hasattr(self, "_testMethodName"):
+ test_name = self._testMethodName
+ sem_conv_mode = "default"
+ if "new_semconv" in test_name:
+ sem_conv_mode = "http"
+ elif "both_semconv" in test_name:
+ sem_conv_mode = "http/dup"
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode,
+ },
+ )
+ self.env_patch.start()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().instrument(meter_provider=self.meter_provider)
+
+ responses.start()
+ responses.add(responses.GET, self.URL, body="Hello!")
+
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+ responses.stop()
+ responses.reset()
+
+ def perform_request(self, url: str) -> niquests.Response: # pylint: disable=no-self-use
+ async def _perform_request():
+ async with AsyncSession() as session:
+ return await session.get(url)
+
+ return _async_call(_perform_request())
+
+ def test_basic_metric_success(self):
+ self.perform_request(self.URL)
+
+ expected_attributes = {
+ HTTP_STATUS_CODE: 200,
+ HTTP_HOST: "examplehost",
+ NET_PEER_PORT: 8000,
+ NET_PEER_NAME: "examplehost",
+ HTTP_METHOD: "GET",
+ HTTP_SCHEME: "http",
+ }
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 1)
+ for metric in metrics:
+ self.assertEqual(metric.unit, "ms")
+ self.assertEqual(
+ metric.description,
+ "measures the duration of the outbound HTTP request",
+ )
+ for data_point in metric.data.data_points:
+ actual = dict(data_point.attributes)
+ # responses mock does not provide http_version
+ actual.pop(HTTP_FLAVOR, None)
+ self.assertDictEqual(expected_attributes, actual)
+ self.assertEqual(data_point.count, 1)
+
+ def test_basic_metric_new_semconv(self):
+ self.perform_request(self.URL)
+
+ expected_attributes = {
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_ADDRESS: "examplehost",
+ SERVER_PORT: 8000,
+ HTTP_REQUEST_METHOD: "GET",
+ }
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 1)
+ for metric in metrics:
+ self.assertEqual(metric.unit, "s")
+ self.assertEqual(
+ metric.description, "Duration of HTTP client requests."
+ )
+ for data_point in metric.data.data_points:
+ actual = dict(data_point.attributes)
+ # responses mock does not provide http_version
+ actual.pop(NETWORK_PROTOCOL_VERSION, None)
+ self.assertDictEqual(expected_attributes, actual)
+ self.assertEqual(data_point.count, 1)
+
+ def test_basic_metric_both_semconv(self):
+ self.perform_request(self.URL)
+
+ expected_attributes_old = {
+ HTTP_STATUS_CODE: 200,
+ HTTP_HOST: "examplehost",
+ NET_PEER_PORT: 8000,
+ NET_PEER_NAME: "examplehost",
+ HTTP_METHOD: "GET",
+ HTTP_SCHEME: "http",
+ }
+
+ expected_attributes_new = {
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_ADDRESS: "examplehost",
+ SERVER_PORT: 8000,
+ HTTP_REQUEST_METHOD: "GET",
+ }
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 2)
+ for metric in metrics:
+ for data_point in metric.data.data_points:
+ actual = dict(data_point.attributes)
+ if metric.unit == "ms":
+ actual.pop(HTTP_FLAVOR, None)
+ self.assertDictEqual(
+ expected_attributes_old,
+ actual,
+ )
+ else:
+ actual.pop(NETWORK_PROTOCOL_VERSION, None)
+ self.assertDictEqual(
+ expected_attributes_new,
+ actual,
+ )
+ self.assertEqual(data_point.count, 1)
+
+
+class TransportMock:
+ def read(self, *args, **kwargs):
+ pass
+
+
+class AsyncMyAdapter(AsyncBaseAdapter):
+ """Async adapter that returns a pre-built Response."""
+
+ def __init__(self, response):
+ super().__init__()
+ self._response = response
+
+ async def send(self, *args, **kwargs): # pylint:disable=signature-differs
+ return self._response
+
+ async def close(self):
+ pass
+
+
+def _make_async_response(ocsp_verified=None, **conn_kwargs):
+ """Build a Response with a mocked conn_info for async adapter tests."""
+ resp = Response()
+ resp.status_code = 200
+ resp.reason = "OK"
+ resp.raw = TransportMock()
+ ci = ConnectionInfo()
+ for key, val in conn_kwargs.items():
+ setattr(ci, key, val)
+ resp.request = type(
+ "StubRequest", (), {"conn_info": ci, "ocsp_verified": ocsp_verified}
+ )()
+ return resp
+
+
+def _async_perform(url, session):
+ """Execute an async GET and return the response."""
+
+ async def _run():
+ async with session:
+ return await session.get(url)
+
+ return _async_call(_run())
+
+
+class TestAsyncConnInfoAndMetrics(TestBase):
+ """Tests for TLS/revocation span attributes and connection sub-duration
+ histograms via the async path (AsyncSession)."""
+
+ URL = "http://mock/status/200"
+ METRIC_URL = "http://examplehost:8000/status/200"
+
+ def setUp(self):
+ super().setUp()
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {OTEL_SEMCONV_STABILITY_OPT_IN: "http"},
+ )
+ self.env_patch.start()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().instrument(
+ tracer_provider=self.tracer_provider,
+ meter_provider=self.meter_provider,
+ )
+
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+
+ def test_tls_attributes_on_span(self):
+ resp = _make_async_response(
+ tls_version=ssl.TLSVersion.TLSv1_3,
+ cipher="TLS_AES_256_GCM_SHA384",
+ )
+ session = AsyncSession()
+ session.mount(self.URL, AsyncMyAdapter(resp))
+ _async_perform(self.URL, session)
+ span = self.memory_exporter.get_finished_spans()[0]
+ self.assertEqual(span.attributes.get("tls.protocol.version"), "1.3")
+ self.assertEqual(
+ span.attributes.get("tls.cipher"), "TLS_AES_256_GCM_SHA384"
+ )
+
+ def test_tls_version_1_2(self):
+ resp = _make_async_response(
+ tls_version=ssl.TLSVersion.TLSv1_2,
+ cipher="ECDHE-RSA-AES128-GCM-SHA256",
+ )
+ session = AsyncSession()
+ session.mount(self.URL, AsyncMyAdapter(resp))
+ _async_perform(self.URL, session)
+ span = self.memory_exporter.get_finished_spans()[0]
+ self.assertEqual(span.attributes.get("tls.protocol.version"), "1.2")
+
+ def test_no_tls_attributes_for_plain_http(self):
+ resp = _make_async_response(destination_address=("127.0.0.1", 8000))
+ session = AsyncSession()
+ session.mount(self.URL, AsyncMyAdapter(resp))
+ _async_perform(self.URL, session)
+ span = self.memory_exporter.get_finished_spans()[0]
+ self.assertIsNone(span.attributes.get("tls.protocol.version"))
+ self.assertIsNone(span.attributes.get("tls.cipher"))
+
+ def test_revocation_verified_true(self):
+ resp = _make_async_response(ocsp_verified=True)
+ session = AsyncSession()
+ session.mount(self.URL, AsyncMyAdapter(resp))
+ _async_perform(self.URL, session)
+ span = self.memory_exporter.get_finished_spans()[0]
+ self.assertTrue(span.attributes.get("tls.revocation.verified"))
+
+ def test_revocation_verified_false(self):
+ resp = _make_async_response(ocsp_verified=False)
+ session = AsyncSession()
+ session.mount(self.URL, AsyncMyAdapter(resp))
+ _async_perform(self.URL, session)
+ span = self.memory_exporter.get_finished_spans()[0]
+ self.assertFalse(span.attributes.get("tls.revocation.verified"))
+
+ def test_revocation_verified_not_set_when_none(self):
+ resp = _make_async_response(ocsp_verified=None)
+ session = AsyncSession()
+ session.mount(self.URL, AsyncMyAdapter(resp))
+ _async_perform(self.URL, session)
+ span = self.memory_exporter.get_finished_spans()[0]
+ self.assertNotIn("tls.revocation.verified", span.attributes)
+
+ def test_connection_latency_metrics(self):
+ resp = _make_async_response(
+ resolution_latency=timedelta(milliseconds=5),
+ established_latency=timedelta(milliseconds=10),
+ tls_handshake_latency=timedelta(milliseconds=15),
+ request_sent_latency=timedelta(milliseconds=2),
+ )
+ session = AsyncSession()
+ session.mount(self.METRIC_URL, AsyncMyAdapter(resp))
+ _async_perform(self.METRIC_URL, session)
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ metric_names = {m.name for m in metrics}
+ expected_metrics = {
+ "http.client.connection.dns.duration",
+ "http.client.connection.tcp.duration",
+ "http.client.connection.tls.duration",
+ "http.client.request.send.duration",
+ }
+ self.assertTrue(expected_metrics.issubset(metric_names))
+ expected_values = {
+ "http.client.connection.dns.duration": 0.005,
+ "http.client.connection.tcp.duration": 0.010,
+ "http.client.connection.tls.duration": 0.015,
+ "http.client.request.send.duration": 0.002,
+ }
+ for metric in metrics:
+ if metric.name in expected_values:
+ self.assertEqual(metric.unit, "s")
+ dp = metric.data.data_points[0]
+ self.assertEqual(dp.count, 1)
+ self.assertAlmostEqual(
+ dp.sum, expected_values[metric.name], places=6
+ )
+
+ def test_connection_metrics_zero_latencies_skipped(self):
+ resp = _make_async_response(
+ resolution_latency=timedelta(0),
+ established_latency=timedelta(0),
+ tls_handshake_latency=timedelta(0),
+ request_sent_latency=timedelta(milliseconds=3),
+ )
+ session = AsyncSession()
+ session.mount(self.METRIC_URL, AsyncMyAdapter(resp))
+ _async_perform(self.METRIC_URL, session)
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ metrics_by_name = {m.name: m for m in metrics}
+ for name in [
+ "http.client.connection.dns.duration",
+ "http.client.connection.tcp.duration",
+ "http.client.connection.tls.duration",
+ ]:
+ metric = metrics_by_name.get(name)
+ if metric is not None:
+ for dp in metric.data.data_points:
+ self.assertEqual(
+ dp.count, 0, f"{name} must skip zero durations"
+ )
+ send_metric = metrics_by_name.get("http.client.request.send.duration")
+ self.assertIsNotNone(send_metric)
+ dp = send_metric.data.data_points[0]
+ self.assertEqual(dp.count, 1)
+ self.assertAlmostEqual(dp.sum, 0.003, places=6)
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_integration.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_integration.py
new file mode 100644
index 0000000000..a961179636
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_integration.py
@@ -0,0 +1,1486 @@
+# 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.
+
+# pylint: disable=too-many-lines
+
+import abc
+import re
+import ssl
+from datetime import timedelta
+from unittest import mock
+
+import niquests
+import responses
+from niquests.adapters import BaseAdapter
+from niquests.models import ConnectionInfo, Response
+
+import opentelemetry.instrumentation.niquests
+from opentelemetry import trace
+from opentelemetry.instrumentation._semconv import (
+ HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
+ HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
+ OTEL_SEMCONV_STABILITY_OPT_IN,
+ _get_schema_url,
+ _OpenTelemetrySemanticConventionStability,
+ _StabilityMode,
+)
+from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+from opentelemetry.instrumentation.utils import (
+ suppress_http_instrumentation,
+ suppress_instrumentation,
+)
+from opentelemetry.propagate import get_global_textmap, set_global_textmap
+from opentelemetry.sdk import resources
+from opentelemetry.semconv._incubating.attributes.http_attributes import (
+ HTTP_FLAVOR,
+ HTTP_HOST,
+ HTTP_METHOD,
+ HTTP_SCHEME,
+ HTTP_STATUS_CODE,
+ HTTP_URL,
+)
+from opentelemetry.semconv._incubating.attributes.net_attributes import (
+ NET_PEER_NAME,
+ NET_PEER_PORT,
+)
+from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
+from opentelemetry.semconv.attributes.http_attributes import (
+ HTTP_REQUEST_METHOD,
+ HTTP_REQUEST_METHOD_ORIGINAL,
+ HTTP_RESPONSE_STATUS_CODE,
+)
+from opentelemetry.semconv.attributes.network_attributes import (
+ NETWORK_PEER_ADDRESS,
+ NETWORK_PEER_PORT,
+ NETWORK_PROTOCOL_VERSION,
+)
+from opentelemetry.semconv.attributes.server_attributes import (
+ SERVER_ADDRESS,
+ SERVER_PORT,
+)
+from opentelemetry.semconv.attributes.url_attributes import URL_FULL
+from opentelemetry.semconv.attributes.user_agent_attributes import (
+ USER_AGENT_ORIGINAL,
+)
+from opentelemetry.test.mock_textmap import MockTextMapPropagator
+from opentelemetry.test.test_base import TestBase
+from opentelemetry.trace import StatusCode
+from opentelemetry.util.http import (
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST,
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE,
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
+ get_excluded_urls,
+)
+
+_NIQUESTS_USER_AGENT = f"niquests/{niquests.__version__}"
+
+
+class TransportMock:
+ def read(self, *args, **kwargs):
+ pass
+
+
+class MyAdapter(BaseAdapter):
+ def __init__(self, response):
+ super().__init__()
+ self._response = response
+
+ def send(self, *args, **kwargs): # pylint:disable=signature-differs
+ return self._response
+
+ def close(self):
+ pass
+
+
+class InvalidResponseObjectException(Exception):
+ def __init__(self):
+ super().__init__()
+ self.response = {}
+
+
+SCOPE = "opentelemetry.instrumentation.niquests"
+
+
+class NiquestsIntegrationTestBase(abc.ABC):
+ # pylint: disable=no-member
+ # pylint: disable=too-many-public-methods
+
+ URL = "http://mock/status/200"
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ super().setUp()
+
+ test_name = ""
+ if hasattr(self, "_testMethodName"):
+ test_name = self._testMethodName
+ sem_conv_mode = "default"
+ if "new_semconv" in test_name:
+ sem_conv_mode = "http"
+ elif "both_semconv" in test_name:
+ sem_conv_mode = "http/dup"
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {
+ "OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS": "http://localhost/env_excluded_arg/123,env_excluded_noarg",
+ OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode,
+ },
+ )
+
+ _OpenTelemetrySemanticConventionStability._initialized = False
+
+ self.env_patch.start()
+
+ self.exclude_patch = mock.patch(
+ "opentelemetry.instrumentation.niquests._excluded_urls_from_env",
+ get_excluded_urls("NIQUESTS"),
+ )
+ self.exclude_patch.start()
+
+ NiquestsInstrumentor().instrument()
+ responses.start()
+ responses.add(responses.GET, self.URL, body="Hello!", status=200)
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ self.exclude_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+ responses.stop()
+ responses.reset()
+
+ def assert_span(self, exporter=None, num_spans=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
+
+ @staticmethod
+ @abc.abstractmethod
+ def perform_request(url: str, session: niquests.Session = 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, "GET")
+
+ self.assertEqual(
+ span.instrumentation_scope.schema_url,
+ _get_schema_url(_StabilityMode.DEFAULT),
+ )
+
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 200,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+
+ self.assertIs(span.status.status_code, trace.StatusCode.UNSET)
+
+ self.assertEqualSpanInstrumentationScope(
+ span, opentelemetry.instrumentation.niquests
+ )
+
+ def test_basic_new_semconv(self):
+ url_with_port = "http://mock:80/status/200"
+ responses.add(responses.GET, url_with_port, status=200, body="Hello!")
+ result = self.perform_request(url_with_port)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "GET")
+
+ self.assertEqual(
+ span.instrumentation_scope.schema_url,
+ _get_schema_url(_StabilityMode.HTTP),
+ )
+ expected = {
+ HTTP_REQUEST_METHOD: "GET",
+ URL_FULL: url_with_port,
+ SERVER_ADDRESS: "mock",
+ NETWORK_PEER_ADDRESS: "mock",
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_PORT: 80,
+ NETWORK_PEER_PORT: 80,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ }
+ # responses mock does not provide raw.version, so
+ # NETWORK_PROTOCOL_VERSION is only present on real connections.
+ if NETWORK_PROTOCOL_VERSION in span.attributes:
+ expected[NETWORK_PROTOCOL_VERSION] = span.attributes[
+ NETWORK_PROTOCOL_VERSION
+ ]
+ self.assertEqual(span.attributes, expected)
+
+ self.assertIs(span.status.status_code, trace.StatusCode.UNSET)
+
+ self.assertEqualSpanInstrumentationScope(
+ span, opentelemetry.instrumentation.niquests
+ )
+
+ def test_basic_both_semconv(self):
+ url_with_port = "http://mock:80/status/200"
+ responses.add(responses.GET, url_with_port, status=200, body="Hello!")
+ result = self.perform_request(url_with_port)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "GET")
+
+ self.assertEqual(
+ span.instrumentation_scope.schema_url,
+ _get_schema_url(_StabilityMode.HTTP),
+ )
+ expected = {
+ HTTP_METHOD: "GET",
+ HTTP_REQUEST_METHOD: "GET",
+ HTTP_URL: url_with_port,
+ URL_FULL: url_with_port,
+ HTTP_HOST: "mock",
+ SERVER_ADDRESS: "mock",
+ NETWORK_PEER_ADDRESS: "mock",
+ NET_PEER_PORT: 80,
+ HTTP_STATUS_CODE: 200,
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_PORT: 80,
+ NETWORK_PEER_PORT: 80,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ }
+ # responses mock does not provide raw.version, so
+ # protocol version attributes are only present on real connections.
+ if HTTP_FLAVOR in span.attributes:
+ expected[HTTP_FLAVOR] = span.attributes[HTTP_FLAVOR]
+ if NETWORK_PROTOCOL_VERSION in span.attributes:
+ expected[NETWORK_PROTOCOL_VERSION] = span.attributes[
+ NETWORK_PROTOCOL_VERSION
+ ]
+ self.assertEqual(span.attributes, expected)
+
+ self.assertIs(span.status.status_code, trace.StatusCode.UNSET)
+
+ self.assertEqualSpanInstrumentationScope(
+ span, opentelemetry.instrumentation.niquests
+ )
+
+ def test_nonstandard_http_method(self):
+ responses.add("NONSTANDARD", self.URL, status=405)
+ session = niquests.Session()
+ session.request("NONSTANDARD", self.URL)
+ span = self.assert_span()
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "HTTP")
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "_OTHER",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 405,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+
+ self.assertIs(span.status.status_code, trace.StatusCode.ERROR)
+
+ def test_nonstandard_http_method_new_semconv(self):
+ responses.add("NONSTANDARD", self.URL, status=405)
+ session = niquests.Session()
+ session.request("NONSTANDARD", self.URL)
+ span = self.assert_span()
+ self.assertIs(span.kind, trace.SpanKind.CLIENT)
+ self.assertEqual(span.name, "HTTP")
+ expected = {
+ HTTP_REQUEST_METHOD: "_OTHER",
+ URL_FULL: self.URL,
+ SERVER_ADDRESS: "mock",
+ NETWORK_PEER_ADDRESS: "mock",
+ HTTP_RESPONSE_STATUS_CODE: 405,
+ ERROR_TYPE: "405",
+ HTTP_REQUEST_METHOD_ORIGINAL: "NONSTANDARD",
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ }
+ if NETWORK_PROTOCOL_VERSION in span.attributes:
+ expected[NETWORK_PROTOCOL_VERSION] = span.attributes[
+ NETWORK_PROTOCOL_VERSION
+ ]
+ self.assertEqual(span.attributes, expected)
+ self.assertIs(span.status.status_code, trace.StatusCode.ERROR)
+
+ def test_hooks(self):
+ def request_hook(span, request_obj):
+ span.update_name("name set from hook")
+
+ def response_hook(span, request_obj, response):
+ span.set_attribute("response_hook_attr", "value")
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(
+ request_hook=request_hook, response_hook=response_hook
+ )
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertEqual(span.name, "name set from hook")
+ self.assertEqual(span.attributes["response_hook_attr"], "value")
+
+ def test_excluded_urls_explicit(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(excluded_urls=".*/404")
+ self.perform_request(self.URL)
+ self.perform_request(url_404)
+
+ self.assert_span(num_spans=1)
+
+ def test_excluded_urls_from_env(self):
+ url = "http://localhost/env_excluded_arg/123"
+ responses.add(responses.GET, url, status=200)
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ self.perform_request(self.URL)
+ self.perform_request(url)
+
+ self.assert_span(num_spans=1)
+
+ def test_name_callback_default(self):
+ def name_callback(method, url):
+ return 123
+
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(name_callback=name_callback)
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+ span = self.assert_span()
+
+ self.assertEqual(span.name, "GET")
+
+ def test_not_found_basic(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+ result = self.perform_request(url_404)
+ self.assertEqual(result.status_code, 404)
+
+ span = self.assert_span()
+
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404)
+
+ self.assertIs(
+ span.status.status_code,
+ trace.StatusCode.ERROR,
+ )
+
+ def test_not_found_basic_new_semconv(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+ result = self.perform_request(url_404)
+ self.assertEqual(result.status_code, 404)
+
+ span = self.assert_span()
+
+ self.assertEqual(span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404)
+ self.assertEqual(span.attributes.get(ERROR_TYPE), "404")
+
+ self.assertIs(
+ span.status.status_code,
+ trace.StatusCode.ERROR,
+ )
+
+ def test_not_found_basic_both_semconv(self):
+ url_404 = "http://mock/status/404"
+ responses.add(responses.GET, url_404, status=404)
+ result = self.perform_request(url_404)
+ self.assertEqual(result.status_code, 404)
+
+ span = self.assert_span()
+
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404)
+ self.assertEqual(span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404)
+ self.assertEqual(span.attributes.get(ERROR_TYPE), "404")
+
+ self.assertIs(
+ span.status.status_code,
+ trace.StatusCode.ERROR,
+ )
+
+ def test_uninstrument(self):
+ NiquestsInstrumentor().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
+ NiquestsInstrumentor().instrument()
+
+ def test_uninstrument_session(self):
+ session1 = niquests.Session()
+ NiquestsInstrumentor().uninstrument_session(session1)
+
+ result = self.perform_request(self.URL, session1)
+ self.assertEqual(result.text, "Hello!")
+ self.assert_span(num_spans=0)
+
+ # Test that other sessions as well as global niquests is still
+ # instrumented
+ session2 = niquests.Session()
+ result = self.perform_request(self.URL, session2)
+ 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()
+
+ def test_suppress_instrumentation(self):
+ with suppress_instrumentation():
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ self.assert_span(num_spans=0)
+
+ def test_suppress_http_instrumentation(self):
+ with suppress_http_instrumentation():
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ self.assert_span(num_spans=0)
+
+ def test_not_recording(self):
+ with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span:
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(
+ tracer_provider=trace.NoOpTracerProvider()
+ )
+ mock_span.is_recording.return_value = False
+ result = self.perform_request(self.URL)
+ 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_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(responses.calls[-1].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_response_hook(self):
+ NiquestsInstrumentor().uninstrument()
+
+ def response_hook(
+ span,
+ request: niquests.PreparedRequest,
+ response: niquests.Response,
+ ):
+ span.set_attribute(
+ "http.response.body", response.content.decode("utf-8")
+ )
+
+ NiquestsInstrumentor().instrument(
+ tracer_provider=self.tracer_provider,
+ response_hook=response_hook,
+ )
+
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 200,
+ "http.response.body": "Hello!",
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+
+ def test_custom_tracer_provider(self):
+ resource = resources.Resource.create({})
+ result = self.create_tracer_provider(resource=resource)
+ tracer_provider, exporter = result
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(tracer_provider=tracer_provider)
+
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span(exporter=exporter)
+ self.assertIs(span.resource, resource)
+
+ @mock.patch(
+ "niquests.adapters.HTTPAdapter.send",
+ side_effect=niquests.RequestException,
+ )
+ def test_requests_exception_without_response(self, *_, **__):
+ with self.assertRaises(niquests.RequestException):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.HTTPAdapter.send",
+ side_effect=niquests.RequestException,
+ )
+ def test_requests_exception_new_semconv(self, *_, **__):
+ url_with_port = "http://mock:80/status/200"
+ responses.add(responses.GET, url_with_port, status=200, body="Hello!")
+ with self.assertRaises(niquests.RequestException):
+ self.perform_request(url_with_port)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_REQUEST_METHOD: "GET",
+ URL_FULL: url_with_port,
+ SERVER_ADDRESS: "mock",
+ SERVER_PORT: 80,
+ NETWORK_PEER_PORT: 80,
+ NETWORK_PEER_ADDRESS: "mock",
+ ERROR_TYPE: "RequestException",
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.HTTPAdapter.send",
+ side_effect=InvalidResponseObjectException,
+ )
+ def test_requests_exception_without_proper_response_type(self, *_, **__):
+ with self.assertRaises(InvalidResponseObjectException):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ mocked_response = niquests.Response()
+ mocked_response.status_code = 500
+ mocked_response.reason = "Internal Server Error"
+
+ @mock.patch(
+ "niquests.adapters.HTTPAdapter.send",
+ side_effect=niquests.RequestException(response=mocked_response),
+ )
+ def test_requests_exception_with_response(self, *_, **__):
+ with self.assertRaises(niquests.RequestException):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ HTTP_METHOD: "GET",
+ HTTP_URL: self.URL,
+ HTTP_STATUS_CODE: 500,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch("niquests.adapters.HTTPAdapter.send", side_effect=Exception)
+ def test_requests_basic_exception(self, *_, **__):
+ with self.assertRaises(Exception):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ @mock.patch(
+ "niquests.adapters.HTTPAdapter.send", side_effect=niquests.Timeout
+ )
+ def test_requests_timeout_exception(self, *_, **__):
+ with self.assertRaises(Exception):
+ self.perform_request(self.URL)
+
+ span = self.assert_span()
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+
+ def test_adapter_with_custom_response(self):
+ response = Response()
+ response.status_code = 210
+ response.reason = "hello adapter"
+ response.raw = TransportMock()
+
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(response))
+
+ self.perform_request(self.URL, session)
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes,
+ {
+ "http.method": "GET",
+ "http.url": self.URL,
+ "http.status_code": 210,
+ USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT,
+ },
+ )
+
+
+class TestNiquestsIntegration(NiquestsIntegrationTestBase, TestBase):
+ @staticmethod
+ def perform_request(url: str, session: niquests.Session = None):
+ if session is None:
+ return niquests.get(url, timeout=5)
+ return session.get(url)
+
+ def test_remove_sensitive_params(self):
+ new_url = (
+ "http://username:password@mock/status/200?AWSAccessKeyId=secret"
+ )
+ responses.add(
+ responses.GET,
+ re.compile(r"http://.*mock/status/200"),
+ body="Hello!",
+ status=200,
+ )
+ self.perform_request(new_url)
+ span = self.assert_span()
+
+ self.assertEqual(
+ span.attributes[HTTP_URL],
+ "http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED",
+ )
+
+ def test_if_headers_equals_none(self):
+ result = niquests.get(self.URL, headers=None, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+ self.assert_span()
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Header,X-Another-Header",
+ },
+ )
+ def test_custom_request_headers_captured(self):
+ """Test that specified request headers are captured as span attributes."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+
+ headers = {
+ "X-Custom-Header": "custom-value",
+ "X-Another-Header": "another-value",
+ "X-Excluded-Header": "excluded-value",
+ }
+ responses.add(responses.GET, self.URL, body="Hello!")
+ result = niquests.get(self.URL, headers=headers, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.x_custom_header"],
+ ("custom-value",),
+ )
+ self.assertEqual(
+ span.attributes["http.request.header.x_another_header"],
+ ("another-value",),
+ )
+ self.assertNotIn("http.request.x_excluded_header", span.attributes)
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Header,X-Another-Header",
+ },
+ )
+ def test_custom_response_headers_captured(self):
+ """Test that specified response headers are captured as span attributes."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+
+ resp_headers = {
+ "X-Custom-Header": "custom-value",
+ "X-Another-Header": "another-value",
+ "X-Excluded-Header": "excluded-value",
+ }
+ responses.replace(
+ responses.GET, self.URL, body="Hello!", headers=resp_headers
+ )
+ result = niquests.get(self.URL, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.response.header.x_custom_header"],
+ ("custom-value",),
+ )
+ self.assertEqual(
+ span.attributes["http.response.header.x_another_header"],
+ ("another-value",),
+ )
+ self.assertNotIn("http.response.x_excluded_header", span.attributes)
+
+ @mock.patch.dict("os.environ", {})
+ def test_custom_headers_not_captured_when_not_configured(self):
+ """Test that headers are not captured when env vars are not set."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ headers = {"X-Request-Header": "request-value"}
+ responses.replace(
+ responses.GET,
+ self.URL,
+ body="Hello!",
+ headers={"X-Response-Header": "response-value"},
+ )
+ result = niquests.get(self.URL, headers=headers, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertNotIn(
+ "http.request.header.x_request_header", span.attributes
+ )
+ self.assertNotIn(
+ "http.response.header.x_response_header", span.attributes
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "Set-Cookie,X-Secret",
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "Authorization,X-Api-Key",
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: "Authorization,X-Api-Key,Set-Cookie,X-Secret",
+ },
+ )
+ def test_sensitive_headers_sanitized(self):
+ """Test that sensitive header values are redacted."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+
+ request_headers = {
+ "Authorization": "Bearer secret-token",
+ "X-Api-Key": "secret-key",
+ }
+ response_headers = {
+ "Set-Cookie": "session=abc123",
+ "X-Secret": "secret",
+ }
+ responses.replace(
+ responses.GET,
+ self.URL,
+ body="Hello!",
+ headers=response_headers,
+ )
+ result = niquests.get(self.URL, headers=request_headers, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.authorization"],
+ ("[REDACTED]",),
+ )
+ self.assertEqual(
+ span.attributes["http.request.header.x_api_key"],
+ ("[REDACTED]",),
+ )
+ self.assertEqual(
+ span.attributes["http.response.header.set_cookie"],
+ ("[REDACTED]",),
+ )
+ self.assertEqual(
+ span.attributes["http.response.header.x_secret"],
+ ("[REDACTED]",),
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Response-.*",
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Request-.*",
+ },
+ )
+ def test_custom_headers_with_regex(self):
+ """Test that header capture works with regex patterns."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ request_headers = {
+ "X-Custom-Request-One": "value-one",
+ "X-Custom-Request-Two": "value-two",
+ "X-Other-Request-Header": "other-value",
+ }
+ response_headers = {
+ "X-Custom-Response-A": "value-A",
+ "X-Custom-Response-B": "value-B",
+ "X-Other-Response-Header": "other-value",
+ }
+ responses.replace(
+ responses.GET,
+ self.URL,
+ body="Hello!",
+ headers=response_headers,
+ )
+ result = niquests.get(self.URL, headers=request_headers, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.x_custom_request_one"],
+ ("value-one",),
+ )
+ self.assertEqual(
+ span.attributes["http.request.header.x_custom_request_two"],
+ ("value-two",),
+ )
+ self.assertNotIn(
+ "http.request.header.x_other_request_header", span.attributes
+ )
+ self.assertEqual(
+ span.attributes["http.response.header.x_custom_response_a"],
+ ("value-A",),
+ )
+ self.assertEqual(
+ span.attributes["http.response.header.x_custom_response_b"],
+ ("value-B",),
+ )
+ self.assertNotIn(
+ "http.response.header.x_other_response_header", span.attributes
+ )
+
+ @mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "x-response-header",
+ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "x-request-header",
+ },
+ )
+ def test_custom_headers_case_insensitive(self):
+ """Test that header capture is case-insensitive."""
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument()
+ request_headers = {"X-ReQuESt-HeaDER": "custom-value"}
+ response_headers = {"X-ReSPoNse-HeaDER": "custom-value"}
+ responses.replace(
+ responses.GET,
+ self.URL,
+ body="Hello!",
+ headers=response_headers,
+ )
+ result = niquests.get(self.URL, headers=request_headers, timeout=5)
+ self.assertEqual(result.text, "Hello!")
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes["http.request.header.x_request_header"],
+ ("custom-value",),
+ )
+ self.assertEqual(
+ span.attributes["http.response.header.x_response_header"],
+ ("custom-value",),
+ )
+
+
+class TestNiquestsIntegrationPreparedRequest(
+ NiquestsIntegrationTestBase, TestBase
+):
+ @staticmethod
+ def perform_request(url: str, session: niquests.Session = None):
+ if session is None:
+ session = niquests.Session()
+ request = niquests.Request("GET", url)
+ prepared_request = session.prepare_request(request)
+ return session.send(prepared_request)
+
+
+class TestNiquestsIntegrationMetric(TestBase):
+ URL = "http://examplehost:8000/status/200"
+
+ def setUp(self):
+ super().setUp()
+ test_name = ""
+ if hasattr(self, "_testMethodName"):
+ test_name = self._testMethodName
+ sem_conv_mode = "default"
+ if "new_semconv" in test_name:
+ sem_conv_mode = "http"
+ elif "both_semconv" in test_name:
+ sem_conv_mode = "http/dup"
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode,
+ },
+ )
+ self.env_patch.start()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().instrument(meter_provider=self.meter_provider)
+
+ responses.start()
+ responses.add(responses.GET, self.URL, body="Hello!")
+
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+ responses.stop()
+ responses.reset()
+
+ @staticmethod
+ def perform_request(url: str) -> niquests.Response:
+ return niquests.get(url, timeout=5)
+
+ def test_basic_metric_success(self):
+ self.perform_request(self.URL)
+
+ expected_attributes = {
+ HTTP_STATUS_CODE: 200,
+ HTTP_HOST: "examplehost",
+ NET_PEER_PORT: 8000,
+ NET_PEER_NAME: "examplehost",
+ HTTP_METHOD: "GET",
+ HTTP_SCHEME: "http",
+ }
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 1)
+ for metric in metrics:
+ self.assertEqual(metric.unit, "ms")
+ self.assertEqual(
+ metric.description,
+ "measures the duration of the outbound HTTP request",
+ )
+ for data_point in metric.data.data_points:
+ self.assertEqual(
+ data_point.explicit_bounds,
+ HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
+ )
+ actual = dict(data_point.attributes)
+ # responses mock does not provide raw.version
+ actual.pop(HTTP_FLAVOR, None)
+ self.assertDictEqual(expected_attributes, actual)
+ self.assertEqual(data_point.count, 1)
+
+ def test_basic_metric_new_semconv(self):
+ self.perform_request(self.URL)
+
+ expected_attributes = {
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_ADDRESS: "examplehost",
+ SERVER_PORT: 8000,
+ HTTP_REQUEST_METHOD: "GET",
+ }
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 1)
+ for metric in metrics:
+ self.assertEqual(metric.unit, "s")
+ self.assertEqual(
+ metric.description, "Duration of HTTP client requests."
+ )
+ for data_point in metric.data.data_points:
+ self.assertEqual(
+ data_point.explicit_bounds,
+ HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
+ )
+ actual = dict(data_point.attributes)
+ # responses mock does not provide raw.version
+ actual.pop(NETWORK_PROTOCOL_VERSION, None)
+ self.assertDictEqual(expected_attributes, actual)
+ self.assertEqual(data_point.count, 1)
+
+ def test_basic_metric_both_semconv(self):
+ self.perform_request(self.URL)
+
+ expected_attributes_old = {
+ HTTP_STATUS_CODE: 200,
+ HTTP_HOST: "examplehost",
+ NET_PEER_PORT: 8000,
+ NET_PEER_NAME: "examplehost",
+ HTTP_METHOD: "GET",
+ HTTP_SCHEME: "http",
+ }
+
+ expected_attributes_new = {
+ HTTP_RESPONSE_STATUS_CODE: 200,
+ SERVER_ADDRESS: "examplehost",
+ SERVER_PORT: 8000,
+ HTTP_REQUEST_METHOD: "GET",
+ }
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 2)
+ for metric in metrics:
+ for data_point in metric.data.data_points:
+ actual = dict(data_point.attributes)
+ if metric.unit == "ms":
+ # responses mock does not provide raw.version
+ actual.pop(HTTP_FLAVOR, None)
+ self.assertDictEqual(
+ expected_attributes_old,
+ actual,
+ )
+ else:
+ actual.pop(NETWORK_PROTOCOL_VERSION, None)
+ self.assertDictEqual(
+ expected_attributes_new,
+ actual,
+ )
+ self.assertEqual(data_point.count, 1)
+
+ def test_custom_histogram_boundaries(self):
+ NiquestsInstrumentor().uninstrument()
+ custom_boundaries = (0.0, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0)
+ meter_provider, memory_reader = self.create_meter_provider()
+ NiquestsInstrumentor().instrument(
+ meter_provider=meter_provider,
+ duration_histogram_boundaries=custom_boundaries,
+ )
+
+ self.perform_request(self.URL)
+ metrics = memory_reader.get_metrics_data().resource_metrics[0]
+ self.assertEqual(len(metrics.scope_metrics), 1)
+ data_point = metrics.scope_metrics[0].metrics[0].data.data_points[0]
+ self.assertEqual(data_point.explicit_bounds, custom_boundaries)
+ self.assertEqual(data_point.count, 1)
+
+ def test_custom_histogram_boundaries_new_semconv(self):
+ NiquestsInstrumentor().uninstrument()
+ custom_boundaries = (0.0, 5.0, 10.0, 25.0, 50.0, 100.0)
+ meter_provider, memory_reader = self.create_meter_provider()
+ NiquestsInstrumentor().instrument(
+ meter_provider=meter_provider,
+ duration_histogram_boundaries=custom_boundaries,
+ )
+
+ self.perform_request(self.URL)
+ metrics = memory_reader.get_metrics_data().resource_metrics[0]
+ self.assertEqual(len(metrics.scope_metrics), 1)
+ data_point = metrics.scope_metrics[0].metrics[0].data.data_points[0]
+ self.assertEqual(data_point.explicit_bounds, custom_boundaries)
+ self.assertEqual(data_point.count, 1)
+
+ def test_basic_metric_non_recording_span(self):
+ expected_attributes = {
+ HTTP_STATUS_CODE: 200,
+ HTTP_HOST: "examplehost",
+ NET_PEER_PORT: 8000,
+ NET_PEER_NAME: "examplehost",
+ HTTP_METHOD: "GET",
+ HTTP_SCHEME: "http",
+ }
+
+ with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span:
+ NiquestsInstrumentor().uninstrument()
+ NiquestsInstrumentor().instrument(
+ tracer_provider=trace.NoOpTracerProvider()
+ )
+ mock_span.is_recording.return_value = False
+ result = self.perform_request(self.URL)
+ self.assertEqual(result.text, "Hello!")
+ 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)
+ metrics = self.get_sorted_metrics(SCOPE)
+ self.assertEqual(len(metrics), 1)
+ duration_data_point = metrics[0].data.data_points[0]
+ actual = dict(duration_data_point.attributes)
+ # responses mock does not provide raw.version
+ actual.pop(HTTP_FLAVOR, None)
+ self.assertDictEqual(expected_attributes, actual)
+ self.assertEqual(duration_data_point.count, 1)
+
+
+class TestNiquestsConnInfoAttributes(TestBase):
+ """Tests for niquests-specific span attributes derived from conn_info.
+
+ Uses a custom adapter so we can inject a Response with realistic
+ ``conn_info`` fields that the ``responses`` mock library does not provide.
+ """
+
+ URL = "http://examplehost:8000/status/200"
+
+ def setUp(self):
+ super().setUp()
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_SEMCONV_STABILITY_OPT_IN: "default",
+ },
+ )
+ self.env_patch.start()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().instrument()
+
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+
+ @staticmethod
+ def perform_request(url: str, session: niquests.Session = None):
+ if session is None:
+ return niquests.get(url, timeout=5)
+ return session.get(url)
+
+ def assert_span(self, num_spans=1):
+ span_list = self.memory_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
+
+ @staticmethod
+ def _make_response_with_conn_info(ocsp_verified=None, **conn_kwargs):
+ """Build a Response with a mocked conn_info.
+
+ The niquests Response.conn_info property delegates to
+ ``self.request.conn_info``, and Response.ocsp_verified delegates to
+ ``self.request.ocsp_verified``. We therefore attach these to a
+ stub request object.
+ """
+ response = Response()
+ response.status_code = 200
+ response.reason = "OK"
+ response.raw = TransportMock()
+
+ ci = ConnectionInfo()
+ for key, val in conn_kwargs.items():
+ setattr(ci, key, val)
+
+ # Attach conn_info and ocsp_verified via a stub request.
+ stub_request = type(
+ "StubRequest",
+ (),
+ {
+ "conn_info": ci,
+ "ocsp_verified": ocsp_verified,
+ },
+ )()
+ response.request = stub_request
+ return response
+
+ def test_tls_attributes_on_span(self):
+ """tls.protocol.version and tls.cipher are set on the span when
+ conn_info has tls_version and cipher."""
+ resp = self._make_response_with_conn_info(
+ tls_version=ssl.TLSVersion.TLSv1_3,
+ cipher="TLS_AES_256_GCM_SHA384",
+ )
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(resp))
+ self.perform_request(self.URL, session)
+
+ span = self.assert_span()
+ self.assertEqual(span.attributes.get("tls.protocol.version"), "1.3")
+ self.assertEqual(
+ span.attributes.get("tls.cipher"), "TLS_AES_256_GCM_SHA384"
+ )
+
+ def test_tls_version_1_2(self):
+ """tls.protocol.version is correctly formatted for TLS 1.2."""
+ resp = self._make_response_with_conn_info(
+ tls_version=ssl.TLSVersion.TLSv1_2,
+ cipher="ECDHE-RSA-AES128-GCM-SHA256",
+ )
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(resp))
+ self.perform_request(self.URL, session)
+
+ span = self.assert_span()
+ self.assertEqual(span.attributes.get("tls.protocol.version"), "1.2")
+
+ def test_no_tls_attributes_for_plain_http(self):
+ """When conn_info has no TLS fields, no tls.* attributes are set."""
+ resp = self._make_response_with_conn_info(
+ destination_address=("127.0.0.1", 8000),
+ )
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(resp))
+ self.perform_request(self.URL, session)
+
+ span = self.assert_span()
+ self.assertIsNone(span.attributes.get("tls.protocol.version"))
+ self.assertIsNone(span.attributes.get("tls.cipher"))
+
+ def test_revocation_verified_true(self):
+ """revocation_verified is set on the span when response.ocsp_verified is True."""
+ resp = self._make_response_with_conn_info(ocsp_verified=True)
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(resp))
+ self.perform_request(self.URL, session)
+
+ span = self.assert_span()
+ self.assertTrue(span.attributes.get("tls.revocation.verified"))
+
+ def test_revocation_verified_false(self):
+ """revocation_verified is set on the span when response.ocsp_verified is False."""
+ resp = self._make_response_with_conn_info(ocsp_verified=False)
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(resp))
+ self.perform_request(self.URL, session)
+
+ span = self.assert_span()
+ self.assertFalse(span.attributes.get("tls.revocation.verified"))
+
+ def test_revocation_verified_not_set_when_none(self):
+ """revocation_verified is NOT set on the span when response.ocsp_verified is None."""
+ resp = self._make_response_with_conn_info(ocsp_verified=None)
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(resp))
+ self.perform_request(self.URL, session)
+
+ span = self.assert_span()
+ self.assertNotIn("tls.revocation.verified", span.attributes)
+
+
+class TestNiquestsConnectionMetrics(TestBase):
+ """Tests for niquests-specific connection sub-duration histogram metrics.
+
+ Uses ``MyAdapter`` to inject a Response with realistic ``conn_info``
+ latency fields, then asserts the histograms are recorded correctly.
+ """
+
+ URL = "http://examplehost:8000/status/200"
+
+ def setUp(self):
+ super().setUp()
+ self.env_patch = mock.patch.dict(
+ "os.environ",
+ {
+ OTEL_SEMCONV_STABILITY_OPT_IN: "http",
+ },
+ )
+ self.env_patch.start()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().instrument(meter_provider=self.meter_provider)
+
+ def tearDown(self):
+ super().tearDown()
+ self.env_patch.stop()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ NiquestsInstrumentor().uninstrument()
+
+ @staticmethod
+ def perform_request(url: str, session: niquests.Session = None):
+ if session is None:
+ return niquests.get(url, timeout=5)
+ return session.get(url)
+
+ def test_connection_latency_metrics(self):
+ """All four connection sub-duration histograms are recorded from conn_info."""
+ ci = ConnectionInfo()
+ ci.resolution_latency = timedelta(milliseconds=5)
+ ci.established_latency = timedelta(milliseconds=10)
+ ci.tls_handshake_latency = timedelta(milliseconds=15)
+ ci.request_sent_latency = timedelta(milliseconds=2)
+
+ response = Response()
+ response.status_code = 200
+ response.reason = "OK"
+ response.raw = TransportMock()
+ response.request = type(
+ "StubRequest",
+ (),
+ {
+ "conn_info": ci,
+ "ocsp_verified": None,
+ },
+ )()
+
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(response))
+ self.perform_request(self.URL, session)
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ metric_names = {m.name for m in metrics}
+
+ expected_metrics = {
+ "http.client.connection.dns.duration",
+ "http.client.connection.tcp.duration",
+ "http.client.connection.tls.duration",
+ "http.client.request.send.duration",
+ }
+ # The main HTTP duration histogram is also present.
+ self.assertTrue(
+ expected_metrics.issubset(metric_names),
+ f"Missing metrics: {expected_metrics - metric_names}",
+ )
+
+ # Verify each connection metric was recorded once with correct value.
+ expected_values = {
+ "http.client.connection.dns.duration": 0.005,
+ "http.client.connection.tcp.duration": 0.010,
+ "http.client.connection.tls.duration": 0.015,
+ "http.client.request.send.duration": 0.002,
+ }
+ for metric in metrics:
+ if metric.name in expected_values:
+ self.assertEqual(metric.unit, "s")
+ dp = metric.data.data_points[0]
+ self.assertEqual(dp.count, 1)
+ self.assertAlmostEqual(
+ dp.sum, expected_values[metric.name], places=6
+ )
+
+ def test_connection_metrics_missing_latencies(self):
+ """When conn_info latencies are None, no connection metrics are recorded."""
+ ci = ConnectionInfo()
+ # All latency fields default to None
+
+ response = Response()
+ response.status_code = 200
+ response.reason = "OK"
+ response.raw = TransportMock()
+ response.request = type(
+ "StubRequest",
+ (),
+ {
+ "conn_info": ci,
+ "ocsp_verified": None,
+ },
+ )()
+
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(response))
+ self.perform_request(self.URL, session)
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ conn_metrics = [
+ metric
+ for metric in metrics
+ if metric.name.startswith("http.client.connection.")
+ or metric.name == "http.client.request.send.duration"
+ ]
+
+ # Connection metrics should exist (histograms are created) but have
+ # zero data points since no latencies were available.
+ for metric in conn_metrics:
+ for dp in metric.data.data_points:
+ self.assertEqual(dp.count, 0)
+
+ def test_connection_metrics_zero_latencies_skipped(self):
+ """Zero-duration latencies (e.g. DNS on a reused connection) must not
+ be recorded so they don't pollute the histogram."""
+ ci = ConnectionInfo()
+ ci.resolution_latency = timedelta(0) # no DNS (reused conn)
+ ci.established_latency = timedelta(0) # no TCP (reused conn)
+ ci.tls_handshake_latency = timedelta(0) # no TLS (reused conn)
+ ci.request_sent_latency = timedelta(milliseconds=3) # always > 0
+
+ response = Response()
+ response.status_code = 200
+ response.reason = "OK"
+ response.raw = TransportMock()
+ response.request = type(
+ "StubRequest",
+ (),
+ {
+ "conn_info": ci,
+ "ocsp_verified": None,
+ },
+ )()
+
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(response))
+ self.perform_request(self.URL, session)
+
+ metrics = self.get_sorted_metrics(SCOPE)
+ metrics_by_name = {m.name: m for m in metrics}
+
+ # The three zero-duration phases must NOT have been recorded.
+ for name in [
+ "http.client.connection.dns.duration",
+ "http.client.connection.tcp.duration",
+ "http.client.connection.tls.duration",
+ ]:
+ metric = metrics_by_name.get(name)
+ if metric is not None:
+ for dp in metric.data.data_points:
+ self.assertEqual(
+ dp.count,
+ 0,
+ f"{name} should not record zero-duration latencies",
+ )
+
+ # request_sent_latency was > 0, so it SHOULD have been recorded.
+ send_metric = metrics_by_name.get("http.client.request.send.duration")
+ self.assertIsNotNone(send_metric)
+ dp = send_metric.data.data_points[0]
+ self.assertEqual(dp.count, 1)
+ self.assertAlmostEqual(dp.sum, 0.003, places=6)
+
+ def test_connection_metrics_no_conn_info(self):
+ """When conn_info is None, no connection metrics are recorded."""
+ response = Response()
+ response.status_code = 200
+ response.reason = "OK"
+ response.raw = TransportMock()
+ # response.request left as None => conn_info returns None
+
+ session = niquests.Session()
+ session.mount(self.URL, MyAdapter(response))
+ self.perform_request(self.URL, session)
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_ip_support.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_ip_support.py
new file mode 100644
index 0000000000..db0a0faa83
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_ip_support.py
@@ -0,0 +1,151 @@
+# 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.
+
+from unittest import mock
+
+import niquests
+
+from opentelemetry import trace
+from opentelemetry.instrumentation._semconv import (
+ OTEL_SEMCONV_STABILITY_OPT_IN,
+ _OpenTelemetrySemanticConventionStability,
+)
+from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+from opentelemetry.semconv.attributes.network_attributes import (
+ NETWORK_PEER_ADDRESS,
+ NETWORK_PEER_PORT,
+ NETWORK_PROTOCOL_VERSION,
+)
+from opentelemetry.test.httptest import HttpTestBase
+from opentelemetry.test.test_base import TestBase
+
+
+class TestNiquestsInstrumentorWithRealSocket(HttpTestBase, TestBase):
+ def setUp(self):
+ super().setUp()
+ self.assert_ip = self.server.server_address[0]
+ self.assert_port = self.server.server_address[1]
+ self.http_host = ":".join(map(str, self.server.server_address[:2]))
+ self.http_url_base = "http://" + self.http_host
+ self.http_url = self.http_url_base + "/status/200"
+ NiquestsInstrumentor().instrument()
+
+ def tearDown(self):
+ super().tearDown()
+ NiquestsInstrumentor().uninstrument()
+
+ @staticmethod
+ def perform_request(url: str) -> niquests.Response:
+ return niquests.get(url, timeout=5)
+
+ def test_basic_http_success(self):
+ response = self.perform_request(self.http_url)
+ self.assert_success_span(response)
+
+ def test_basic_http_success_using_connection_pool(self):
+ with niquests.Session() as session:
+ response = session.get(self.http_url)
+
+ self.assert_success_span(response)
+
+ # Test that when re-using an existing connection, everything still works.
+ # Especially relevant for IP capturing.
+ response = session.get(self.http_url)
+
+ self.assert_success_span(response)
+
+ def test_http_version_format(self):
+ """Verify the HTTP version attribute uses the correct semconv format.
+
+ The ``network.protocol.version`` attribute must be a bare version
+ string such as ``"1.1"`` – NOT ``"HTTP/1.1"``.
+ """
+ response = self.perform_request(self.http_url)
+ self.assertEqual("Hello!", response.text)
+
+ span = self.assert_span()
+ # The test HTTP server speaks HTTP/1.1
+ version = span.attributes.get("http.flavor")
+ if version is not None:
+ self.assertNotIn("HTTP/", version)
+ self.assertEqual("1.1", version)
+
+ def test_http_version_new_semconv(self):
+ """Verify network.protocol.version is set in new semconv mode."""
+ NiquestsInstrumentor().uninstrument()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ with mock.patch.dict(
+ "os.environ",
+ {OTEL_SEMCONV_STABILITY_OPT_IN: "http"},
+ ):
+ NiquestsInstrumentor().instrument()
+ response = self.perform_request(self.http_url)
+ self.assertEqual("Hello!", response.text)
+
+ span = self.assert_span()
+ version = span.attributes.get(NETWORK_PROTOCOL_VERSION)
+ self.assertIsNotNone(version)
+ self.assertNotIn("HTTP/", version)
+ self.assertEqual("1.1", version)
+
+ _OpenTelemetrySemanticConventionStability._initialized = False
+
+ def test_ip_new_semconv_attributes(self):
+ """Verify network.peer.address and network.peer.port are set from
+ conn_info when using the new semantic conventions."""
+ NiquestsInstrumentor().uninstrument()
+ _OpenTelemetrySemanticConventionStability._initialized = False
+ with mock.patch.dict(
+ "os.environ",
+ {OTEL_SEMCONV_STABILITY_OPT_IN: "http"},
+ ):
+ NiquestsInstrumentor().instrument()
+ response = self.perform_request(self.http_url)
+ self.assertEqual("Hello!", response.text)
+
+ span = self.assert_span()
+ # network.peer.address should be the actual IP, not the hostname
+ self.assertEqual(
+ span.attributes.get(NETWORK_PEER_ADDRESS), self.assert_ip
+ )
+ self.assertEqual(
+ span.attributes.get(NETWORK_PEER_PORT), self.assert_port
+ )
+ # In new-only mode, old semconv net.peer.ip must NOT be set.
+ self.assertIsNone(span.attributes.get("net.peer.ip"))
+
+ _OpenTelemetrySemanticConventionStability._initialized = False
+
+ def assert_span(self, num_spans=1): # TODO: Move this to TestBase
+ span_list = self.memory_exporter.get_finished_spans()
+ self.assertEqual(num_spans, len(span_list))
+ if num_spans == 0:
+ return None
+ self.memory_exporter.clear()
+ if num_spans == 1:
+ return span_list[0]
+ return span_list
+
+ def assert_success_span(self, response: niquests.Response):
+ self.assertEqual("Hello!", response.text)
+
+ span = self.assert_span()
+ self.assertIs(trace.SpanKind.CLIENT, span.kind)
+ self.assertEqual("GET", span.name)
+
+ attributes = {
+ "http.status_code": 200,
+ "net.peer.ip": self.assert_ip,
+ }
+ self.assertGreaterEqual(span.attributes.items(), attributes.items())
diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_user_agent_synthetic.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_user_agent_synthetic.py
new file mode 100644
index 0000000000..37c9888e57
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_user_agent_synthetic.py
@@ -0,0 +1,202 @@
+# 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.
+
+from unittest import mock
+
+import niquests
+import responses
+
+from opentelemetry.instrumentation.niquests import NiquestsInstrumentor
+from opentelemetry.semconv._incubating.attributes.user_agent_attributes import (
+ USER_AGENT_ORIGINAL,
+ USER_AGENT_SYNTHETIC_TYPE,
+ UserAgentSyntheticTypeValues,
+)
+from opentelemetry.test.test_base import TestBase
+
+
+class TestUserAgentSynthetic(TestBase):
+ URL = "http://mock/status/200"
+
+ def setUp(self):
+ super().setUp()
+ NiquestsInstrumentor().instrument()
+ responses.start()
+ responses.add(responses.GET, self.URL, body="Hello!")
+
+ def tearDown(self):
+ super().tearDown()
+ NiquestsInstrumentor().uninstrument()
+ responses.stop()
+ responses.reset()
+
+ def assert_span(self, num_spans=1):
+ span_list = self.memory_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
+
+ def test_user_agent_bot_googlebot(self):
+ """Test that googlebot user agent is marked as 'bot'"""
+ headers = {
+ "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
+ }
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.BOT.value,
+ )
+
+ def test_user_agent_bot_bingbot(self):
+ """Test that bingbot user agent is marked as 'bot'"""
+ headers = {
+ "User-Agent": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
+ }
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.BOT.value,
+ )
+
+ def test_user_agent_test_alwayson(self):
+ """Test that alwayson user agent is marked as 'test'"""
+ headers = {"User-Agent": "AlwaysOn-Monitor/1.0"}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.TEST.value,
+ )
+
+ def test_user_agent_case_insensitive(self):
+ """Test that detection is case insensitive"""
+ headers = {"User-Agent": "GOOGLEBOT/2.1"}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.BOT.value,
+ )
+
+ self.memory_exporter.clear()
+
+ headers = {"User-Agent": "ALWAYSON-Monitor/1.0"}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.TEST.value,
+ )
+
+ def test_user_agent_normal_browser(self):
+ """Test that normal browser user agents don't get synthetic type"""
+ headers = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ }
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertNotIn(USER_AGENT_SYNTHETIC_TYPE, span.attributes)
+
+ def test_no_user_agent_header(self):
+ """Test that requests without user agent don't get synthetic type"""
+ niquests.get(self.URL, timeout=5)
+
+ span = self.assert_span()
+ self.assertNotIn(USER_AGENT_SYNTHETIC_TYPE, span.attributes)
+
+ def test_empty_user_agent_header(self):
+ """Test that empty user agent doesn't get synthetic type"""
+ headers = {"User-Agent": ""}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertNotIn(USER_AGENT_SYNTHETIC_TYPE, span.attributes)
+
+ def test_user_agent_substring_match(self):
+ """Test that substrings are detected correctly"""
+ # Test googlebot in middle of string
+ headers = {"User-Agent": "MyApp/1.0 googlebot crawler"}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.BOT.value,
+ )
+
+ self.memory_exporter.clear()
+
+ # Test alwayson in middle of string
+ headers = {"User-Agent": "TestFramework/1.0 alwayson monitoring"}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.TEST.value,
+ )
+
+ def test_user_agent_priority_alwayson_over_bot(self):
+ """Test that alwayson takes priority if both patterns match"""
+ headers = {"User-Agent": "alwayson-googlebot/1.0"}
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ # alwayson should be checked first and return 'test'
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.TEST.value,
+ )
+
+ def test_user_agent_bytes_like_header(self):
+ """Test that bytes-like user agent headers are handled."""
+
+ original_prepare_headers = (
+ niquests.models.PreparedRequest.prepare_headers
+ )
+
+ def prepare_headers_bytes(self, headers):
+ original_prepare_headers(self, headers)
+ if "User-Agent" in self.headers:
+ value = self.headers["User-Agent"]
+ if isinstance(value, str):
+ self.headers["User-Agent"] = value.encode("utf-8")
+
+ headers = {"User-Agent": "AlwaysOn-Monitor/1.0"}
+ with mock.patch(
+ "niquests.models.PreparedRequest.prepare_headers",
+ new=prepare_headers_bytes,
+ ):
+ niquests.get(self.URL, headers=headers, timeout=5)
+
+ span = self.assert_span()
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_SYNTHETIC_TYPE),
+ UserAgentSyntheticTypeValues.TEST.value,
+ )
+ self.assertEqual(
+ span.attributes.get(USER_AGENT_ORIGINAL),
+ "AlwaysOn-Monitor/1.0",
+ )
diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml
index 630509a4b2..4c9941e029 100644
--- a/opentelemetry-contrib-instrumentations/pyproject.toml
+++ b/opentelemetry-contrib-instrumentations/pyproject.toml
@@ -58,6 +58,7 @@ dependencies = [
"opentelemetry-instrumentation-logging==0.63b0.dev",
"opentelemetry-instrumentation-mysql==0.63b0.dev",
"opentelemetry-instrumentation-mysqlclient==0.63b0.dev",
+ "opentelemetry-instrumentation-niquests==0.63b0.dev",
"opentelemetry-instrumentation-pika==0.63b0.dev",
"opentelemetry-instrumentation-psycopg==0.63b0.dev",
"opentelemetry-instrumentation-psycopg2==0.63b0.dev",
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py
index 999181bd9f..db6de0aff2 100644
--- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py
@@ -136,6 +136,10 @@
"library": "mysqlclient < 3",
"instrumentation": "opentelemetry-instrumentation-mysqlclient==0.63b0.dev",
},
+ {
+ "library": "niquests >= 3.0, < 4",
+ "instrumentation": "opentelemetry-instrumentation-niquests==0.63b0.dev",
+ },
{
"library": "pika >= 0.12.0",
"instrumentation": "opentelemetry-instrumentation-pika==0.63b0.dev",
diff --git a/pyproject.toml b/pyproject.toml
index 92cfe8b5d2..d02bc29e64 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,7 @@ dependencies = [
"opentelemetry-instrumentation-redis[instruments]",
"opentelemetry-instrumentation-remoulade[instruments]",
"opentelemetry-instrumentation-requests[instruments]",
+ "opentelemetry-instrumentation-niquests[instruments]",
"opentelemetry-instrumentation-sqlalchemy[instruments]",
"opentelemetry-instrumentation-sqlite3",
"opentelemetry-instrumentation-system-metrics",
@@ -124,6 +125,7 @@ opentelemetry-instrumentation-pyramid = { workspace = true }
opentelemetry-instrumentation-redis = { workspace = true }
opentelemetry-instrumentation-remoulade = { workspace = true }
opentelemetry-instrumentation-requests = { workspace = true }
+opentelemetry-instrumentation-niquests = { workspace = true }
opentelemetry-instrumentation-sqlalchemy = { workspace = true }
opentelemetry-instrumentation-sqlite3 = { workspace = true }
opentelemetry-instrumentation-system-metrics = { workspace = true }
diff --git a/tox.ini b/tox.ini
index 1f2ca7f8e9..a522ea5bc8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -189,6 +189,10 @@ envlist =
;pypy3-test-instrumentation-requests
lint-instrumentation-requests
+ ; opentelemetry-instrumentation-niquests
+ py3{10,11,12,13,14}-test-instrumentation-niquests
+ lint-instrumentation-niquests
+
; opentelemetry-instrumentation-starlette
py3{10,11,12,13,14}-test-instrumentation-starlette-{oldest,latest}
pypy3-test-instrumentation-starlette-{oldest,latest}
@@ -672,6 +676,9 @@ deps =
requests: {[testenv]test_deps}
requests: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-requests/test-requirements.txt
+ niquests: {[testenv]test_deps}
+ niquests: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt
+
starlette: {[testenv]test_deps}
starlette-{oldest,lint}: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-starlette/test-requirements.oldest.txt
starlette-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-starlette/test-requirements.latest.txt
@@ -959,6 +966,9 @@ commands =
test-instrumentation-requests: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-requests/tests {posargs}
lint-instrumentation-requests: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-requests"
+ test-instrumentation-niquests: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-niquests/tests {posargs}
+ lint-instrumentation-niquests: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-niquests"
+
test-instrumentation-sqlalchemy: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests {posargs}
lint-instrumentation-sqlalchemy: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-sqlalchemy"
diff --git a/uv.lock b/uv.lock
index 14d5ea35b6..9a0c984f51 100644
--- a/uv.lock
+++ b/uv.lock
@@ -49,6 +49,7 @@ members = [
"opentelemetry-instrumentation-logging",
"opentelemetry-instrumentation-mysql",
"opentelemetry-instrumentation-mysqlclient",
+ "opentelemetry-instrumentation-niquests",
"opentelemetry-instrumentation-openai-agents-v2",
"opentelemetry-instrumentation-openai-v2",
"opentelemetry-instrumentation-pika",
@@ -1967,6 +1968,84 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
+[[package]]
+name = "jh2"
+version = "5.0.11"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ea/ebd7cbba422317fdd4be5e04a5aa9a54192b8c483f20eb38c85cf9fb8adc/jh2-5.0.11.tar.gz", hash = "sha256:6c835b0b38d795dde7aaa4581626490ca5fcfbd4eefe9572ac18d9eb2427d215", size = 7320877, upload-time = "2026-04-05T07:33:51.119Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/d8/4f885e71a3dd9a89b92d7db1888ab1c0ddb1fc85d733b593fdef50fcc090/jh2-5.0.11-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cf85910f5d8506467e9a6fc9be3140f4ffe49e2baa973b71c83822c6e6e88480", size = 606617, upload-time = "2026-04-05T07:30:50.965Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/55/cba74b4b3093504f7c50424a5decd0ce27a8e86669d8006c4facbedde5ad/jh2-5.0.11-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86d1bd875161ce4d5303e667ad19fb7436476d1610aa04b21c14838c1669f32a", size = 384519, upload-time = "2026-04-05T07:30:53.05Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/7e/c34f56f9d1d68e5aa96ddcf1ad4b74701f1d8318a4fafe6dd4e512f6eda2/jh2-5.0.11-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e20f3bcf50192caea969b4bb674c8f6dc607fb5f8abe6b76248f698e9e4cab84", size = 392005, upload-time = "2026-04-05T07:30:54.81Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/19/a2840d299c85ae861ed1020f37865913853963ccd49b8a723d09270bd7e8/jh2-5.0.11-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8afe44228388f9282b4e3804e0212fc7f000ede156e73b2068f61fb821598c9f", size = 512189, upload-time = "2026-04-05T07:30:56.633Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/7c/c11a478e6ba7243f2617cfdba868970acaef1f64d523659cccc2eeb67519/jh2-5.0.11-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176f4de35aef5f3eef38d6ae785bb530f911af1fc6a21512da620250cde95a94", size = 505287, upload-time = "2026-04-05T07:30:58.111Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/01/c8b6803f4d6cf30b47d40d093099eea0c06cdce82af4f2632480d284a9ba/jh2-5.0.11-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa8c32df2426f7a9d8633c2c8b5555edcace6e703640cb50f7ecb5732d9b50c", size = 405622, upload-time = "2026-04-05T07:30:59.903Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/66/95e4111fa3da434e41f71bf3d57faa5c2989f3f99d263bb572552bbf502e/jh2-5.0.11-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af8d4f64794823fcdaa1ab4d01e40361e0dc0ddda9a6523e96a72b47a9e96e7f", size = 389889, upload-time = "2026-04-05T07:31:01.647Z" },
+ { url = "https://files.pythonhosted.org/packages/11/e4/aacbd532a1bb67a253d9b75c7b3b8351c24142b7a88ba87cdb96d3459ea4/jh2-5.0.11-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:611c474b2c998fb09f5825dbe88626cc86c991a6d7dbc4c0d2a0848fa2fa437d", size = 408073, upload-time = "2026-04-05T07:31:03.083Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/12/2bfb901198043395f56c5b10cd573b55ae490a3c670bb29294bd2d5e67ec/jh2-5.0.11-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c39968cf5547d68f97a893c518f02f6caca94942206a958d8aa9325f8c3e330", size = 560987, upload-time = "2026-04-05T07:31:04.507Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c7/96fdd7285dec74e6e3daab7983cd03053046ccaaa9b767ff12064b0b525b/jh2-5.0.11-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:7c19511733a8ccf998042b64ac2077c334d73f2d0df4ce80b158694191a1f707", size = 666856, upload-time = "2026-04-05T07:31:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c6/2ea20b5282b6fff062902b203cbaf6e5e2315ef9aac881635c025fd49bf5/jh2-5.0.11-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:fa18a2886a229a0d53a2c6c3d109079cdd2550466f2ae2286e43a1c66d47d627", size = 625983, upload-time = "2026-04-05T07:31:08.251Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/06/fbaf3037c9b2e909a66334e5d342eb0ac0179963d33b757ba104e1efe619/jh2-5.0.11-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:5d6a1000872f99a2d50316bdab7dcb8a9eccebd1c7ca4ba2e656a74ee48015ac", size = 593492, upload-time = "2026-04-05T07:31:10.189Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/f5/c35622ed1cbdda8fe8677a1bcff090f95db9f85dfbb9de1aa2fdbf7db02c/jh2-5.0.11-cp313-cp313t-win32.whl", hash = "sha256:89c46416ccf0f457bfd4df67670c79052116f07ffb3951c5103d178c6bf372ec", size = 237224, upload-time = "2026-04-05T07:31:12.409Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/5f/4296893bef63687498722638dfcef85610cfd232e97088dc067ee874c331/jh2-5.0.11-cp313-cp313t-win_amd64.whl", hash = "sha256:cd4187891ebc44e782c5606393e16818d63bc1dbd3a0028bafed62e2d0fdd3f2", size = 244265, upload-time = "2026-04-05T07:31:14.13Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/cc/48e8b6220eb17bec051cccc262935a1389a4e09557168c9a9a15bd378672/jh2-5.0.11-cp313-cp313t-win_arm64.whl", hash = "sha256:dfb99fe1bd951d2da7d5dd90325c8a3c3834dd614339f536a45cbd1bc1335f1e", size = 240543, upload-time = "2026-04-05T07:31:15.484Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/51/f8dd078ba26c709cb89b48eef0259c714956e74bc5be1a1db48e40eaebbb/jh2-5.0.11-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:92ff21001d59d47f929418d0dae55a97be16221c13e1f7ed134bdc79189475fb", size = 606275, upload-time = "2026-04-05T07:31:17.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/09/f94b94f2a7683c6a5c1a1bcfbc34bb7fbcb09e16014dd7b8172bc7a365f1/jh2-5.0.11-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc22823c633e95c6b5298f9ffe2d77f0f1787f2d03c47ccb7dff006e6c30fac3", size = 384500, upload-time = "2026-04-05T07:31:19.083Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/e8/b0994158d6181be0c8e5674b6a988db0712f1afd1e9f8df2c2ae6faf90fb/jh2-5.0.11-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08392b71819ef4dec683010b0366b15da8ed495250110c6009833f25855ab6a4", size = 392121, upload-time = "2026-04-05T07:31:20.966Z" },
+ { url = "https://files.pythonhosted.org/packages/61/8a/d2c2352bb21d69b6a8c678c21d8ca54d00474f80e3273151575eb9342a5a/jh2-5.0.11-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3ebfcd80cfcaa17bbb5733871953d1df79e1cc8bdc0f22d7372d9f2ef3524008", size = 511896, upload-time = "2026-04-05T07:31:23.204Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/94/20522d75f8beaf036541b012947ddc834a649bd1be6b4fc6126fe9225aa4/jh2-5.0.11-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f26cdbf79bd0792bc65b7825b356040c56c365041a6ae7c44e5655f8fa173fe6", size = 505414, upload-time = "2026-04-05T07:31:25.104Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e7/df5ddd84b1d0c2e70c244a9a51b31d93f77ebd7f7eddfc79a3a3d66f5e3e/jh2-5.0.11-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d7e43c6248e3a091e9f6c5aac23236bd7ba0e30d240f4017b644bd3da049688", size = 405467, upload-time = "2026-04-05T07:31:27Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8d/c3f00aaeb516926d35482d0b5f62dde8bfa66b8b1e89107df4e1c3699e1a/jh2-5.0.11-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34c0bbf4688917a3a1b1dba176bc49bb5b1ad4b75765431b989f7767061df432", size = 390021, upload-time = "2026-04-05T07:31:28.585Z" },
+ { url = "https://files.pythonhosted.org/packages/40/dc/8f3e7d0eeb6bbd7ef2c9fb186f6a38e1b883e9db7622a17050594be82b66/jh2-5.0.11-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:740e4e489759b749aaed695e8430d28a039c11765fc5e4d1b20bfad9c7e192f1", size = 408069, upload-time = "2026-04-05T07:31:30.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/9f/820f50745957e491e081b39b1333a6bf0893a9528002133951b9f6fe9fc9/jh2-5.0.11-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:57601fd1c5f6fce9e63ea1f2a61f83784478cf4d58e8491a7c18cc05abdb8e96", size = 560975, upload-time = "2026-04-05T07:31:31.64Z" },
+ { url = "https://files.pythonhosted.org/packages/00/c2/a658a73429bd96cf3c12dbfa9270e5ffb117d1d3207fbe0e72ebaff1fdb5/jh2-5.0.11-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:c7834d1000ac856234e7b574ed2ccf2136aab325d84051edb1db06c17e295df4", size = 667148, upload-time = "2026-04-05T07:31:33.523Z" },
+ { url = "https://files.pythonhosted.org/packages/be/a3/d5a789af902526265143631d14ad07f9dda88afa71e99b4f31495d7a0f53/jh2-5.0.11-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:985a9eb136e7897bcedba873cf30b51c19481d94ab31a391d05eeecf27c390ba", size = 625865, upload-time = "2026-04-05T07:31:34.912Z" },
+ { url = "https://files.pythonhosted.org/packages/18/97/129ec3c3ca5d46d4eff1b230623a98c0cc93d85663850533ba4b416eead8/jh2-5.0.11-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3c3b06db73cde4e350e8acd5960e6bd9880e512cc8ab9c28003c74414261382b", size = 593557, upload-time = "2026-04-05T07:31:36.363Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5a/e8eb939e21b3d963206081d0bc9eb17b494b4747ea537e70337f5c5ae7d3/jh2-5.0.11-cp314-cp314t-win32.whl", hash = "sha256:ebe5ec3b51704119ca66717828631a777bc64132517f445d0b9ac2f30dd38264", size = 237324, upload-time = "2026-04-05T07:31:37.708Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/57/298d81b6477bfe573b4a02234c39c06ddcbb906114f339d119253bef4a3b/jh2-5.0.11-cp314-cp314t-win_amd64.whl", hash = "sha256:05102a4610dde1dc59c630e64ca34a74076d1afd275dbeac954b230a605788b9", size = 244785, upload-time = "2026-04-05T07:31:39.411Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/a5/3f6477c76630134ac3db8093b64b07fa3c15f03d7274836656dce53d7585/jh2-5.0.11-cp314-cp314t-win_arm64.whl", hash = "sha256:7a1388738fcce0ddc8e742d2d1c0619911299f339d54a19496bcbecfb4d7e775", size = 240967, upload-time = "2026-04-05T07:31:40.883Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f0/0363ffb6ed11a76d47c6a543f16c3c3e6efedc27853fa1ef95df557c0724/jh2-5.0.11-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4dc82aee3ab2c4103f3d9092f4463dd6cc4a248ab6a27a4acab79bef0d3ac8dd", size = 622317, upload-time = "2026-04-05T07:31:42.411Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/61/fc161568713450214d3c48db614d871f9393bd88db471e10ac868f5a7214/jh2-5.0.11-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85cf4f09f7159c29967212af685d2819f960d9136d931420fef107683d121f56", size = 393320, upload-time = "2026-04-05T07:31:43.814Z" },
+ { url = "https://files.pythonhosted.org/packages/68/0e/f300ee75f1bd3c8212d084d4e52a83e81fca2dbfe4c10c4b97bb4a8b9743/jh2-5.0.11-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daadac34cefe67ea03a7d2324e03fc9b37ec8820604f1563e7d424471bee29b6", size = 399898, upload-time = "2026-04-05T07:31:45.376Z" },
+ { url = "https://files.pythonhosted.org/packages/94/74/cd7e97f87e85ddfb0449ff50d23c0ed3967348fd4c79917972ac0e277e84/jh2-5.0.11-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:558d4c15bc42419262ef15595d9c488ae53276b562397314e1cc934f4c7e4bdf", size = 520449, upload-time = "2026-04-05T07:31:47.158Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9b/b94e76661c2ade180654687cb7819106427a711e6b40c31cb5c41cac33fa/jh2-5.0.11-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c61f837b3e5c897bd2a90149afd615f59d24c72c893526485bca1b40f6ec49", size = 512366, upload-time = "2026-04-05T07:31:48.644Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/de/ddba9eb5ca665bcf557cb28b99e96a06df1b570bcfc086ab0bd21b1232f0/jh2-5.0.11-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a433f014c207ffc4b3eda0165fbd4d7d978b53cbdd6e71d441531221b2b1b879", size = 414743, upload-time = "2026-04-05T07:31:50.365Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/b80fd188fa006a144cd4f280fdfd3808e3715aaa805fa830e828406080ed/jh2-5.0.11-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d15672b32f0891940691bac16a854af164e694f0b9d21bebfddd13e3c7d2f03", size = 399371, upload-time = "2026-04-05T07:31:51.855Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/48/d5ead4a7379bcc0386baed535183f8168b2bda0cf3e368586f6d4ab44024/jh2-5.0.11-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbf08eead0483ecaf275c2f447b704d1583278f7abd6f0e945fccd6a581c7df4", size = 418311, upload-time = "2026-04-05T07:31:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/8d/59dd21f1ed6f451f60581522e08e42f3e74275b1568ef9c7936a06445108/jh2-5.0.11-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:6cd51dd02943b703e10eb536722c5fd205b6084333dac5b9c114bdbbc2c46b3a", size = 569633, upload-time = "2026-04-05T07:31:55.228Z" },
+ { url = "https://files.pythonhosted.org/packages/28/0a/e53f7b8c13638c712d7ad1b8f0af29ec97a46ae67d912e501ce0a606f869/jh2-5.0.11-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:58f4c9da6555923f731e358d975e40d4ae6241c05b29e5a0f4dc8c91781cc229", size = 675463, upload-time = "2026-04-05T07:31:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/5a/e02ad465d53f144a3d5a7b52cf57e6f14800ec65ac6c90d081dbdf9c507d/jh2-5.0.11-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6ba33ff1d1275586bb4d83687c59783dad60b66ef3d420c04982bae7e0d75f9b", size = 636580, upload-time = "2026-04-05T07:31:59.03Z" },
+ { url = "https://files.pythonhosted.org/packages/82/55/db34614186693ce66c69af384659c5f37fa79699c81c8867cec2fe4dc566/jh2-5.0.11-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b0ad821964a7701e2b80c6f8b424b6d4ca575fefb1aa04227967ef78fa15fcd5", size = 603360, upload-time = "2026-04-05T07:32:00.681Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/07/1490e419fe03ec9afb7c2571cfd3410d34bd5367dc36bbbc9d52d21f44ae/jh2-5.0.11-cp37-abi3-win32.whl", hash = "sha256:18f10dcf0aa9f19833ac0f4d58b195af2d0b056423d428f74bf03f7839db8055", size = 245065, upload-time = "2026-04-05T07:32:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/60/69a4fcc00a01fa65bbf67279e21886325087491250bc54af3e12e86c8532/jh2-5.0.11-cp37-abi3-win_amd64.whl", hash = "sha256:e28dabffcbd5525bf5f36d482764e3e56b513bce06a75b2fb4b540bedad80348", size = 251415, upload-time = "2026-04-05T07:32:04.021Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/9e/33ba56e964b58b056a4c17003c0be245be3dcfda17e9f1df95cd5209ece8/jh2-5.0.11-cp37-abi3-win_arm64.whl", hash = "sha256:dfbb07be66cb96a289c876aaab7ac46da4fb70f6526298f1fda60076b971d5f0", size = 247273, upload-time = "2026-04-05T07:32:05.771Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e7/e6d5f717a5383c0384882add7410060694533efcfe7051c26ce21301d6d0/jh2-5.0.11-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:842f27673350dc22659cc0dfba035bf610927810fcfb6a9ddea594dcb3cfe774", size = 619041, upload-time = "2026-04-05T07:32:07.241Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/c6/3df905164fde3793d579bcb46e9cf1d246d902a6c7e6c19db49a99166aec/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdb4bc4cb82e66a41d4f4ec9fb80f7ed7981cf7786efb6a94cf47ea27ec90e28", size = 390780, upload-time = "2026-04-05T07:32:09.01Z" },
+ { url = "https://files.pythonhosted.org/packages/92/88/db049e5bf4e1514c5328e1c5ce3caa6ab081c56c12617061827d9fa9ef72/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31ddace327ac78b3137d79c4ce1a64fb8d5b1256a88078e7806b20280a22ed1c", size = 398254, upload-time = "2026-04-05T07:32:10.756Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/79/74501282f0482105dc752230ecb9bec865ea07d8344cd81b87860805090c/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fc2bdcc2fecb3e3382bbf2834d81b981c3b8978be67c54c2aeeba01dee911161", size = 517176, upload-time = "2026-04-05T07:32:12.825Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/d2/a261ad53dc5f7b52066d33fd7eb58119285382bacd786002be2b7ddf6d14/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da8fbddf3e00a9e8e18afda67721e71cc69f1a81ba16a4a7b50f57efa47d2991", size = 509867, upload-time = "2026-04-05T07:32:14.71Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/a8/c1d72032e4d4ef05146f12496b91037ad785f0c281a80eaf20fe40909bb6/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97da940c5bc7f9ca1ddde294fac46f75ca4be2a7b7be5a32e87b6195f2f4203d", size = 411500, upload-time = "2026-04-05T07:32:16.057Z" },
+ { url = "https://files.pythonhosted.org/packages/89/f4/cb7a4469d1738aea67f7786164b5d63b36d78fc185a6036ce7f582dae51a/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73db6c7374aebb94e2758e1c34c0090b1dee39f13a28b812387c8e9478cdbee1", size = 397270, upload-time = "2026-04-05T07:32:17.515Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/6d/3059c02b3b1193fdf87fb65b7b4dc4562028992ed2cd14cc3a67ca8fcd71/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf2a83fcbcd3dd53b80574655a54459e7ddd591d936ac67e636330764c75907", size = 415937, upload-time = "2026-04-05T07:32:19.008Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/7c/d8672da7971dec40cca96ea6a6f8574bc4d13958f427d5eda7ee9c92ea9e/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1517147850dd3bcf8e3204e7e4b4016e47440a889accfd6b055734dd2686bc89", size = 567045, upload-time = "2026-04-05T07:32:20.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/5c/5c3b2d7def23f4c4c53b7af7723a6086de6e77169ab14a96dc3ef37c1ee6/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c0693e9efcb492f48b61453b6fca3cee60c544c494fa1eb7ab63dbe493189db4", size = 673554, upload-time = "2026-04-05T07:32:22.508Z" },
+ { url = "https://files.pythonhosted.org/packages/71/91/c1cf8168392aedb0eade45df2b99c1239bfd31f52542f527ad472e2ad0b8/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:e5616ffeb5b173b540e2db230546a476c5618cf25dac5bb9149c06fc6b0a9e4f", size = 634024, upload-time = "2026-04-05T07:32:24.485Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/69/6b33f1b2ff6ff77add2f09580d7a5499f0f1d402ce4b8d45bb267394b61b/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2f88407c7d2de429346e589ede8f0eaa594d6a8a3e388b658bbf4828998a53a0", size = 600998, upload-time = "2026-04-05T07:32:25.922Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/45/10a5d15a93d15aae67b6dd15042b248ed1230c0dbeddce4c897f8cda4849/jh2-5.0.11-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd19d6e0f8b82dd92e9e9836baf8c5d3a18d15d06c7838c64dae0f45b0cad24d", size = 613531, upload-time = "2026-04-05T07:32:27.445Z" },
+ { url = "https://files.pythonhosted.org/packages/60/9f/c97912c0c1ec27f128c0a64e9c61fdcc695512f81c7bd6b40af9b774a891/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31d5c24cc3c20b49ad00e25e2d429d51b240a7f7fc8910c48a1fa11cf84f0c71", size = 388839, upload-time = "2026-04-05T07:32:29.197Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d3/7c690b4d93a838581a4179f981f562e3987d2813275cd9f1b6b4e886ce5a/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:264b93edeb9368cd4ad8b8ba4a23e9404ba6a449ded00f6c1e62b259fabf43c8", size = 394652, upload-time = "2026-04-05T07:32:31.137Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/53/3ea94d6b30f7d1987b0ec1b4579f74e9878b63b61290ecd63610ebb639ce/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6dc37f78187655cf95032bee0e9578ff89d39734def8e27a6bf930d4caa08042", size = 513824, upload-time = "2026-04-05T07:32:32.527Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/c92cd7ac9a831319d9b73746a59590aaa2a795b3e5c9fa25302296414486/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d4c26ef61db31c6f33a572b40c6eff312131cac83300bbf6d75fbed1e5f073e", size = 509687, upload-time = "2026-04-05T07:32:33.936Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/20/7c897e440bb082468412bc0a8b11507d5268e1c29145ed31c0a47d7dae67/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:070adb3943f306257fff6dff4cbdcb5324afd78cbfa624f6686f198e6381d707", size = 408435, upload-time = "2026-04-05T07:32:37.212Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/79/75a88341a7a4d03ddb400062c0240bae5f41a64df68c83b2195029c83188/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6c5421e4eb59f9f15822b9002b26a78a8f9d4e507e4f79d6f3d5f992db4be0a", size = 394025, upload-time = "2026-04-05T07:32:38.711Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c9/abe96cc1d6c3e7d7e1154f26c72217ed0f0700fe42f60b9b02ba508ecc2a/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f37f6497f8dbb1c1e254c77224ad06cfde22c1337230d308aeaab043eea27", size = 412872, upload-time = "2026-04-05T07:32:40.155Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ce/d199ea2f446ec54d8aa1ab7671b6d1990bb9a00b6f17e3525ea628c8ca8a/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:559c5ff8034ce36aa7c24c6acb80d4dc2a377ff552ff5c58be6d8762ed8ee048", size = 564809, upload-time = "2026-04-05T07:32:41.835Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/56/c3e04580552c734b8d494f25d75c6c9a22f8ed93cf52ba01973e7d379bda/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:148195763588b0b8003fc365783838fabdc8450346b7c8df0c7945b80f252fab", size = 670095, upload-time = "2026-04-05T07:32:43.513Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b1/dfd0d84f7a3acb9e4b4707aa503de01350ab23fd4699fe7ff4b6c4059393/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:f639bbe255623af299f75b2ef8e98b0c88ead8b9f420d20abe487fb8a33238b6", size = 631038, upload-time = "2026-04-05T07:32:45.002Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ab/72eef3a401485d76c3123d871429a02d68f705632c3c89eb55e032c5aa7f/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3dea67e8ae492168e4271351dca8869e4bf79f3bb45d301d54f9639e8cb345ae", size = 596729, upload-time = "2026-04-05T07:32:46.419Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/17/512d0ac0484aca136e698498d6441b6072156fb2d618d8096a07578f67ad/jh2-5.0.11-py3-none-any.whl", hash = "sha256:aafd357af8d0de5267d3bc88e2384da30f05c38446a61425ae565925bc2ca9ba", size = 98207, upload-time = "2026-04-05T07:33:49.43Z" },
+]
+
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -2601,6 +2680,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/e5/037d55623be9f681236e04abe12e1290847c06bd48270c3f19ac33493cbf/mysqlclient-2.2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:260cce0e81446c83bf0a389e0fae38d68547d9f8fc0833bc733014e10ce28a99", size = 213067, upload-time = "2026-02-10T10:58:43.389Z" },
]
+[[package]]
+name = "niquests"
+version = "3.18.6"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "charset-normalizer" },
+ { name = "urllib3-future" },
+ { name = "wassima", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/02/ff649fdaf4a9019c4ba71c038ec8355a1404538eb8bde2028a3956003c39/niquests-3.18.6.tar.gz", hash = "sha256:bdf8e945241670608552e8b3c41556aaf532705e3c8f1ed197a102d017f0a54a", size = 1022813, upload-time = "2026-04-13T06:32:38.628Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/3c/78f9a77e4fdd4456088bb7f0a5b2e99ba5d6cbf41fc36f5cbb3f3103a42f/niquests-3.18.6-py3-none-any.whl", hash = "sha256:cb415e31ca18ec69e88f393dcc339bce8baa38d50910e696fd0712d0b9480551", size = 208665, upload-time = "2026-04-13T06:32:37.223Z" },
+]
+
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -3605,6 +3698,31 @@ requires-dist = [
]
provides-extras = ["instruments"]
+[[package]]
+name = "opentelemetry-instrumentation-niquests"
+source = { editable = "instrumentation/opentelemetry-instrumentation-niquests" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "opentelemetry-util-http" },
+]
+
+[package.optional-dependencies]
+instruments = [
+ { name = "niquests" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "niquests", marker = "extra == 'instruments'", specifier = ">=3.0,<4" },
+ { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" },
+ { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" },
+ { name = "opentelemetry-util-http", editable = "util/opentelemetry-util-http" },
+]
+provides-extras = ["instruments"]
+
[[package]]
name = "opentelemetry-instrumentation-openai-agents-v2"
source = { editable = "instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2" }
@@ -4269,6 +4387,7 @@ dependencies = [
{ name = "opentelemetry-instrumentation-logging" },
{ name = "opentelemetry-instrumentation-mysql", extra = ["instruments"] },
{ name = "opentelemetry-instrumentation-mysqlclient", extra = ["instruments"] },
+ { name = "opentelemetry-instrumentation-niquests", extra = ["instruments"] },
{ name = "opentelemetry-instrumentation-openai-v2", extra = ["instruments"] },
{ name = "opentelemetry-instrumentation-pika", extra = ["instruments"] },
{ name = "opentelemetry-instrumentation-psycopg", extra = ["instruments"] },
@@ -4342,6 +4461,7 @@ requires-dist = [
{ name = "opentelemetry-instrumentation-logging", editable = "instrumentation/opentelemetry-instrumentation-logging" },
{ name = "opentelemetry-instrumentation-mysql", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-mysql" },
{ name = "opentelemetry-instrumentation-mysqlclient", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-mysqlclient" },
+ { name = "opentelemetry-instrumentation-niquests", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-niquests" },
{ name = "opentelemetry-instrumentation-openai-v2", extras = ["instruments"], editable = "instrumentation-genai/opentelemetry-instrumentation-openai-v2" },
{ name = "opentelemetry-instrumentation-pika", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-pika" },
{ name = "opentelemetry-instrumentation-psycopg", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-psycopg" },
@@ -5477,6 +5597,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "qh3"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/93/2d/fe3fb2cb618191dcaa0f9fbeb98498641a8148cfa0a5086b7298d9b7b7ac/qh3-1.7.1.tar.gz", hash = "sha256:4ce90c54ab94521840248522de2b6620f302cde8ff317333f40f405907e6b6ad", size = 285895, upload-time = "2026-03-30T07:16:06.378Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/12/3d525052d5a162844224257e25fe92404b8f6cc23b4af0a63ca8a2c42516/qh3-1.7.1-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3b9187a6bc52bcc51ed5a68b19049c9d1650d418ddfc09a626c07a7f6b564690", size = 4163347, upload-time = "2026-03-30T07:12:50.882Z" },
+ { url = "https://files.pythonhosted.org/packages/27/d2/b07ec4f6f0d50c6d66397c3339b1749db11e6c8ae6dfe95a87a5bbada62b/qh3-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc581f04e89a1f6203d591627fd95e4e3065d0c1c4b420d522f3870a70672b38", size = 2024849, upload-time = "2026-03-30T07:12:52.511Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/3e/717041af707b817d25c3849fa81cc2470feec68b72eaafcb5dc844072dc7/qh3-1.7.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d81637c8f9535afcc54c1c5207b39bd6101ed6ad6b75e46c5fa33d6857dba4bb", size = 1740965, upload-time = "2026-03-30T07:12:54.389Z" },
+ { url = "https://files.pythonhosted.org/packages/25/79/71fa2989aec7f2a7d332d764f99f45c5be14f5f460875c8758476574d222/qh3-1.7.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1f41cc6c352ffe710577a6cd815e3d3d54e2c5eecfa2d6f4d79cd8c58e958e4", size = 1906267, upload-time = "2026-03-30T07:12:55.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/70/e7616fa858cae162071fa5653530eb525de6796d24bfd859dc57a6a89972/qh3-1.7.1-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7a1e6eda1e07b4d84ab1f4c1a800e038be3d186efd8baf8ff8f82d232cc8619c", size = 1890175, upload-time = "2026-03-30T07:12:57.317Z" },
+ { url = "https://files.pythonhosted.org/packages/53/5e/27ba052c101ad53861177b5412e890d2c1ec51c43fa994f2875745355321/qh3-1.7.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81ae86a1d6ccaf2ede39c923458e95e797a2dc2d07177dca9e9a43d59b8adb2", size = 1892092, upload-time = "2026-03-30T07:12:58.831Z" },
+ { url = "https://files.pythonhosted.org/packages/93/87/dcc22baf27e0a32bd6701a0bbf5429ff89956c535c62e5276c1e90eb3974/qh3-1.7.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36021bfc874156084b0e4f8bc06a88101702177fb62c865d34a7ea160f430ca", size = 1963167, upload-time = "2026-03-30T07:13:00.886Z" },
+ { url = "https://files.pythonhosted.org/packages/df/25/dd8f9bc6f33aa5503b723bbff4759bf919ea45bd7d14b1c79e095342c180/qh3-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:719ae1dabc838e5007a5017c7b5bc6fd7cc3f4f66239f730eb7a3eef9774dbcf", size = 2248764, upload-time = "2026-03-30T07:13:02.443Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/26/6a3cf0c7864d89f27c824f5f98e5b6d13d8a4c7d9bcc1dc3d896f6e39bd4/qh3-1.7.1-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:46b1971681a6df9afafb62fe80d9e13841a25b1393ab7e52716b7651d117d3af", size = 1897795, upload-time = "2026-03-30T07:13:03.96Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/bf/4827edc3d764bb0936f731c48621fc39d4d17b6e70a8ea7157210fe7d11e/qh3-1.7.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:97003692047afe27ea8e5406200c2222f4a2e5ef798e8b569658ded1098013af", size = 2204417, upload-time = "2026-03-30T07:13:05.45Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/f2/ff372f2e771f15507ea2f3a221e3f595484231b300d9a19b165f9bc45c63/qh3-1.7.1-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:c5da8f36885e3bce9772bff5fa6174c342f8cb6d154bfdde35202ea4e153d266", size = 1993585, upload-time = "2026-03-30T07:13:06.802Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/41/915e63657c989ef8fd4a4f136fc9aca04ebcfb9ea4368b8906ab6f42a977/qh3-1.7.1-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:ffb7d4e5f0a60543d02921b3339032105c22ec555419fd02e239cf462ce788c5", size = 2094312, upload-time = "2026-03-30T07:13:08.636Z" },
+ { url = "https://files.pythonhosted.org/packages/58/8e/ced596a5d2108ba8cc767788376d63a6c27896672cfbb9fc8d2480646f50/qh3-1.7.1-cp313-cp313t-musllinux_1_1_riscv64.whl", hash = "sha256:ef32df648cfe6e39a9254610ffd7106329b3d3d60641d82b74fb7806b56653ba", size = 2008494, upload-time = "2026-03-30T07:13:10.494Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/99/bf9d5e2c23aeede12e890f3281ec441caeb8708e26b73e7e5efd17b976e2/qh3-1.7.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:22245715a045db9eb2a6a78febdb1c1c223662219b640dc04e5a2ef48d923af3", size = 2459464, upload-time = "2026-03-30T07:13:11.936Z" },
+ { url = "https://files.pythonhosted.org/packages/66/8a/84c7bc3858b0cc41b9b4466fc8c19af4e806a20791b9c2db9f216c0f5e69/qh3-1.7.1-cp313-cp313t-win32.whl", hash = "sha256:8e6ff471d92ed428d888767395f38bc37ae6ae6f72e8e5cf433b3f7310627b92", size = 1755710, upload-time = "2026-03-30T07:13:13.456Z" },
+ { url = "https://files.pythonhosted.org/packages/de/12/a8ba7940a51e51b2ed4e49944c3f18531bfb41b53d1aefe92b41b972d9a0/qh3-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:de62e1831d9239229507d24c8c09713766f9ca39ed0614a71aa994df38da73d5", size = 2003211, upload-time = "2026-03-30T07:13:14.865Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/76/e0db34a6e80a588dc52b156078ba6dd52c98202d9186a5a9c23d074536a1/qh3-1.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:1f6adecb0607ee2dd634e6af6d7676dda7fa2ba1fb1b4c6ae25c861d0fe064c1", size = 1840827, upload-time = "2026-03-30T07:13:16.489Z" },
+ { url = "https://files.pythonhosted.org/packages/79/49/73e7b33f12d9ba318933dd83e7f2e596f1ac7e175f62daa91e2ca5f54b07/qh3-1.7.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d3f305f3d55a7bd1d235537829d3e4704bf15478657434fc29809e1130aa00d3", size = 4163579, upload-time = "2026-03-30T07:13:18.14Z" },
+ { url = "https://files.pythonhosted.org/packages/68/38/4912c46758c13f2dd7f93a58be9b22ac87ef0e47f18645046edf2a7d6f7f/qh3-1.7.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad639b734f8e91fc1c97801fb16e504c08e3ad1bad3f18b1e71e5736cf079872", size = 2025078, upload-time = "2026-03-30T07:13:19.792Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/41/68bc9c9470f51a261e0528864c70e2019b150fc3ba076ea5dfe766823788/qh3-1.7.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9777367bee6bb7500faf4cfacf2741464ea4f30221e8d6298899a398452de2dc", size = 1741439, upload-time = "2026-03-30T07:13:21.181Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/fa/d682a279da99ca3978922a247a70e8471850d5095fd3a1f9497d77262f91/qh3-1.7.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29ae0d8c96b9bd3e6c1bf107aba402d0a140117ca38d05e65ce97cd66a8766b", size = 1906206, upload-time = "2026-03-30T07:13:22.492Z" },
+ { url = "https://files.pythonhosted.org/packages/95/17/45101128d70dca398fe7668bbde603aba7808f0bc216d799918752a35421/qh3-1.7.1-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cfe638402e76d7f54183a5e3b365f9fcfc6a5e367556f827ac124c6f1ce26b7e", size = 1890431, upload-time = "2026-03-30T07:13:23.896Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/b7/9aa1beff114488278a155af6b1b8258ac0a8ef0a98e1107c93c2b4ab75cf/qh3-1.7.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cc90002b5581580526d7d7f11b3359693cc09f43dc28a4433daab2194149ad8", size = 1892506, upload-time = "2026-03-30T07:13:25.549Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e3/bceaa72e9a95d18df5874f85d5e7889ae3362250327b24bc16c571a57fba/qh3-1.7.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0bb89e1afe81ddf2226baa2b837cec0773fe0b099a3145ebbfbc520e46f1da30", size = 1963256, upload-time = "2026-03-30T07:13:27.117Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/bf/6006185e904523d006b9e11be1b9a0ad5b1adcea9f1872f77271462c1388/qh3-1.7.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3a32657aea7e48baf590b81f030325be6f69d7cb771893302abee915cfe3be", size = 2249446, upload-time = "2026-03-30T07:13:28.616Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/42/67405ee7031e86ad99379f6c7421fa2ae1893c330800ba24b7bdf4b798f0/qh3-1.7.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:c0da3ddf00b978dc8b9d8fc550498839d4e4afbf59538837dc6b4209b90ec281", size = 1898225, upload-time = "2026-03-30T07:13:30.365Z" },
+ { url = "https://files.pythonhosted.org/packages/14/ce/7713db24b6624e0c60f5c3895750f4c57b288a889ea7adcd3a80cea2908f/qh3-1.7.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec10cd0caf4938e63bf90fffcc6485eedccb77df449d7631f6bbcbaf8688ff84", size = 2204645, upload-time = "2026-03-30T07:13:31.876Z" },
+ { url = "https://files.pythonhosted.org/packages/00/72/6056ab22b239d3edeb6e6acd25e43f9479d2acc50531eeaaa4d1769124a1/qh3-1.7.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a8f1cb3e1ac71dd221feb4f5feeeb5171a2d1f16e531ef53b8a9a0b2dd2c275c", size = 1994040, upload-time = "2026-03-30T07:13:33.362Z" },
+ { url = "https://files.pythonhosted.org/packages/24/81/d9fdf1a1cbe86d49202375f69ce2e923cd7274a7dc39b71422ce72072c43/qh3-1.7.1-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:59e0ba0db9e68cfeba5ea8a0c119777f92c6fb5850307ab7e98eb8c388bedf7f", size = 2095538, upload-time = "2026-03-30T07:13:34.923Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d9/99f75449f021c3c5bc0f215503158591a1f8794bd435f5a92f86b0941fff/qh3-1.7.1-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:eaff3423e2145c41db0e914f0e624c4f01e443533f4ca849c289cb0018f0b800", size = 2008870, upload-time = "2026-03-30T07:13:36.537Z" },
+ { url = "https://files.pythonhosted.org/packages/35/66/4b963671fde5b4b1721e3285ec65dae9b79897d3284bc71d0844edf28f5d/qh3-1.7.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0c6b008df6aed81f71daf1e44f56e96e73b6b9c5df8b018af0cc98e196f3bbd0", size = 2459870, upload-time = "2026-03-30T07:13:37.958Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d6/327ad6aac7b5c8b74cfd70e875791c868cd41d175cd871a7ef206491b1f2/qh3-1.7.1-cp314-cp314t-win32.whl", hash = "sha256:be271db1d7df7bf56dd7c2b1ddb37a679de913c96ab3f2e9be85de96d973bfd7", size = 1755903, upload-time = "2026-03-30T07:13:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e4/2a5551af4f44aa5b150f4b0d8a86b974d8f42fe567645b2ad1b1ab056714/qh3-1.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb1c03009fefe3b3d89de18d81edea934d276d8d0817fedd9ab34226e9d92cda", size = 2003442, upload-time = "2026-03-30T07:13:40.748Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/c1/62238dc67a0c99b330d48f8642b23ce9bc7a8c5705fbb1406d609cc68348/qh3-1.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:60a9044c3f758d4b8abc9f414bae29cce986fdae12d2ace6b733c2306423cddc", size = 1841401, upload-time = "2026-03-30T07:13:42.207Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9d/37c127a2fde22787c8a265cb3f3541cfaa3599725e9667a4c8a4672cfa92/qh3-1.7.1-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bb58ce71850c302f35cf961a5f218e562a934fdead0ae32883b3d2bf632d5d42", size = 4174574, upload-time = "2026-03-30T07:13:43.939Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/f6/cfe4a6d8cd45aeb4d472154e4f2776a9574df78d612bb275cbac19dc47a6/qh3-1.7.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca2b3ed1407d2e0898369b7ebf2075b4e856d89c9fdabd0b93943b6350a3378b", size = 2028163, upload-time = "2026-03-30T07:13:45.371Z" },
+ { url = "https://files.pythonhosted.org/packages/89/11/1871830752405356b25e283d8eb09579e663a3869330d7b940510cee110e/qh3-1.7.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4276453d9887326d55ecc2fed07d1569c7ecc8d31b40ff0d2488b7587b340f94", size = 1743072, upload-time = "2026-03-30T07:13:47.213Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0a/187a271b4d5f8bfeee2b7149efb504897e583e87c37e8f11bb5bc2c1d0ef/qh3-1.7.1-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee7375faf82ae7acb32cf238b551886bf58b4456152b7c0c478c0d44b34165b3", size = 1911137, upload-time = "2026-03-30T07:13:48.719Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/46/164affc8b9c785fffb10a4dffed0c543a95422fdf80c6a8823a8603092dc/qh3-1.7.1-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df1d99471f5074faadea2cee7ebbd4ccd44e9b234c8350fb56003227a7890b9b", size = 1893576, upload-time = "2026-03-30T07:13:50.489Z" },
+ { url = "https://files.pythonhosted.org/packages/69/54/1661d594e1fb3fdfef5c062e49c4e79e24336585a3668a88fefb10615284/qh3-1.7.1-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8c37aff831446f8dc7e131c52cd89f6f5b6ac03b0e7adf9d35818c014c51884", size = 1898396, upload-time = "2026-03-30T07:13:52.285Z" },
+ { url = "https://files.pythonhosted.org/packages/29/07/adba3ec305e2240dc7c3795cceac8fd1cbb2ca802bf3e6e7d16cfed644fb/qh3-1.7.1-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47a457645ab86d41d06ef7e3486bdf419b06f1392ba20858ec9d492b2862ce0f", size = 1965969, upload-time = "2026-03-30T07:13:53.798Z" },
+ { url = "https://files.pythonhosted.org/packages/23/a1/67b3b59025aada229faec97de60df31ce765bf42dd3adcce9a638150f6e7/qh3-1.7.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dab8eb5b4bd3777f3f7a823f57e759e65f558c239c32030918dcd078c01f8734", size = 2252690, upload-time = "2026-03-30T07:13:55.325Z" },
+ { url = "https://files.pythonhosted.org/packages/02/be/a190715ceb0890de6a7693a4c34ae1a559cb74031db30a2c50d6c8d2abee/qh3-1.7.1-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:cc9beb80baddcc1755869e0da07c25dbb0515d41d8e53b6a6984d0ea769f066e", size = 1899680, upload-time = "2026-03-30T07:13:57.153Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b9/95cdcb0e2b76808813a86d520ceb30171f343555e922a627d58b015ce0c1/qh3-1.7.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3bc0f9714f5a5a08a3bd80d9597e59358cbbe1333661feda29e249689ad1fd19", size = 2207162, upload-time = "2026-03-30T07:13:58.868Z" },
+ { url = "https://files.pythonhosted.org/packages/47/eb/9186a4c96e3a319ae37fef801663667b0e9ce5c1881b8c4f1d6cb8c610b5/qh3-1.7.1-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:e2b5f2d607f8a4982f540a2f3de9477b8e5c2c8cd5557dad2ed80cd33cb58b4c", size = 1995378, upload-time = "2026-03-30T07:14:00.264Z" },
+ { url = "https://files.pythonhosted.org/packages/32/27/ac4d939baa3d9c11aaa37856ccd4e776c1329bb9fc58214741e1344c4f8f/qh3-1.7.1-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:5a478b4733dbb7f37694e2045ae706542f734d9033438e55bd38bf93eda0f093", size = 2099056, upload-time = "2026-03-30T07:14:02.039Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/ab/81c4e026c1cf81f8988f5a178d05ce56c7819b24e52cc913a3db6ecf3cac/qh3-1.7.1-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:5cd1f93e905ceec99217ceb1c2a971b430d69101e74423d271616173ab738dab", size = 2010716, upload-time = "2026-03-30T07:14:03.461Z" },
+ { url = "https://files.pythonhosted.org/packages/35/eb/951451ae4a7a899a7cf3ae2d796178f814ce27f423a97ca29314a33d1ce7/qh3-1.7.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:23ed67a177ec6b8453a23bcff7fdad47307f54edaf32f968f95adb665cc04ae3", size = 2463359, upload-time = "2026-03-30T07:14:05.371Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/dd/1ed9e4fe7d277981b5d169b506b63007c22d73151497875b7414611f568e/qh3-1.7.1-cp37-abi3-win32.whl", hash = "sha256:591e3a2801973b88705cff3624b24436ec16d61f55bc37ed6ce417e998a77f30", size = 1759471, upload-time = "2026-03-30T07:14:06.902Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/4d/75efaefbf24cdb13fcee77f77ac6ba6ac51a6428995386962b9d21768307/qh3-1.7.1-cp37-abi3-win_amd64.whl", hash = "sha256:2e1a36665f93f50ff70dfdc6c4d4873bc7fb99210caf8806a52452fa813fea8f", size = 2009871, upload-time = "2026-03-30T07:14:08.44Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/ac/2e962684bc88a3fb1ffc40e9e058e7d5d9c1e88390f260e49e58f72cea06/qh3-1.7.1-cp37-abi3-win_arm64.whl", hash = "sha256:f07701c40d6b012b91756cc1c59805f72dd4706159fde66e0759cff411705d41", size = 1846336, upload-time = "2026-03-30T07:14:09.931Z" },
+ { url = "https://files.pythonhosted.org/packages/09/53/3c0988d3e15c303a32850d8a28410f29c80468367d18b7ae51e0e981c700/qh3-1.7.1-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:17cbd39d2742e9364519e1de1f88ba5d604e6f3e54f67911378f866098c97dd9", size = 4173089, upload-time = "2026-03-30T07:14:11.429Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/0d/545fa1d86354af756ef1368ab22bf231391bc7dafa050efa9faaac40c51e/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a13155b0052925aef2ed676f16307d6f96837532701d21d45ad905963010fc7", size = 2027416, upload-time = "2026-03-30T07:14:13.277Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/57/fcd8077a44f4dd740a7b77063984cb0167bd334fc7279c20cad1905eabca/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50cad9d0cbb6e1c594ba53aab02739cde78f2ba4bc9bfce4112ca825f10d3f87", size = 1743241, upload-time = "2026-03-30T07:14:14.765Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/91/f312ba38cebb8115dfb918851964b30ce9028f6aa0c14b5eb9fcc3e16a25/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6baa755b7c6613db9c0b6e9127b9ffb461e3a49942b7749be77be1563946bf5b", size = 1911117, upload-time = "2026-03-30T07:14:16.83Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/db/9825d63418ad350c6ec1b09df9e7054cd3ea38cc0f13f0358dd0c0f997ad/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8ed56f58cb5ff28b09a6fd0cc9120354c5bd4d9e21c5d4e3bc187df998730233", size = 1892355, upload-time = "2026-03-30T07:14:18.352Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a6/821daf3fd1597fc07c2ab89ba4918b5f6164fd5072e5c20d4792139028a4/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:665e2f4ffcce119574b5c3fe9b8b074f555af378abb89f87edf28be2527b1a5a", size = 1897754, upload-time = "2026-03-30T07:14:19.889Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/ef/57175cc7912615c887e455354d056a58cc317123b64ccba3cec244f025d8/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28e74a7f3aafcf4fd73958d43fc9d4f179eb8df5640e7f305cbe8e2feb398a66", size = 1965297, upload-time = "2026-03-30T07:14:21.394Z" },
+ { url = "https://files.pythonhosted.org/packages/40/6d/1a0ff86c2aae4d4a6c92289ae25db78b27b619aae636a76c6b7093cfc11a/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d08119d6f2e7169fc9f489cbb970899cf8b327702c0d9c4f361ce57fb9c5d838", size = 2252148, upload-time = "2026-03-30T07:14:22.982Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/25/df01c673117b9b22ecf027e394f9d2e74717c8b025a222d47fd177aac359/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d076bc6f45b0819f9c03f172c4f99e4be179f675683fb9d716a89a29c4705fd8", size = 2206735, upload-time = "2026-03-30T07:14:24.801Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/21/db6fc62041d388f28b2434c47570ae5b75cc41905b4abd2a849974401a56/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:60b79ecf604ae91ce272abeaf78d5363997e3491d0d7628fd343a945c49f9dd0", size = 1995569, upload-time = "2026-03-30T07:14:26.709Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/24/cb5a5120ec011ecda6344eb2ab118264a93813df3d1a4bcd6de17c8f3517/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:1012236eb0728584c523d1af1929b5fd2eaee39b2c685d1eb750a3139121e8bf", size = 2098538, upload-time = "2026-03-30T07:14:28.54Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/477d6d3d93818394554bab2bb2e2597f411044dddfedd615c6dd9e6e1d00/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:87b83bdf37a1984cf755fde3996aeeea59ae9391669e587f20ce4814202de513", size = 2462757, upload-time = "2026-03-30T07:14:30.061Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/26/b8a0e5d5b4a7ae3158eab9e00b6d99725bcf567fc5fd3aec14d85f63504b/qh3-1.7.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ed790ec68c91919612844ffb79a0a5338d9b63dc89fa797251cdd0228d49b19a", size = 2008275, upload-time = "2026-03-30T07:14:32.019Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/b6822439b7b2f0efa34227305ec37ed6ff556636a724b29376430e7a8065/qh3-1.7.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04a794aba81d5690cdc21353e7972883e38cb01633d266b58efebd2332c0bfd0", size = 4168204, upload-time = "2026-03-30T07:14:33.688Z" },
+ { url = "https://files.pythonhosted.org/packages/51/08/ef1dfaa7f0accb364a951119dfc31e2b67d33f2bada8ea12fa4588cc7561/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff747f7bc8e89ace9af99fb87f1f21ac390d1ce69e427c51884400157d75af89", size = 2025003, upload-time = "2026-03-30T07:14:35.278Z" },
+ { url = "https://files.pythonhosted.org/packages/09/28/3f82686147ab3b46edcfffcf275e5e0c845f1a447063ef3e5f88b64c9635/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb264eb9734cba1df742619ff579d43e929b46c2d381709b9b941dc5470e6f34", size = 1740716, upload-time = "2026-03-30T07:14:36.996Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/0d/a185b7358068b26ccc10f50eeead8c2cd5cbbd374b888c7fd6bddeccdea7/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7de1c807d42cc9c9ec2e22c3154a1f06541096daf49a8f5834c304266ad7e2e3", size = 1907838, upload-time = "2026-03-30T07:14:38.668Z" },
+ { url = "https://files.pythonhosted.org/packages/56/7e/6877b0050830baca67b4a2999fabc6b501ffc6fb719f666a43b877478f13/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:daeda5590afda408a1fb843003b25e8f9586ec66aab83531303b65cb1862fab9", size = 1890962, upload-time = "2026-03-30T07:14:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/33/51/d3349b04185e3cf467c0628ce5f739ec19412eeb92180c00e8f884297ca5/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200098eb39c9c76a6461b2b96e868fdfe64a808c4ea34c285dfda28985da75ff", size = 1893298, upload-time = "2026-03-30T07:14:42.155Z" },
+ { url = "https://files.pythonhosted.org/packages/95/56/c36a3ab985218a73e91ae991a6e7d876838bb4e27acae1e7e6c32ad65701/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcedba51fa9cc3560c3448202b7eda3e9902e738dadda9428155f308b6036e22", size = 1961984, upload-time = "2026-03-30T07:14:43.752Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/4a/2f2c806bc9f0b674264ceba5460ff99a6ef5455c03abba050d90b1a0a968/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a5b4e137ac65c388789aaf68627d49fe38138d81513e52e509c4b4d317f8678", size = 2249372, upload-time = "2026-03-30T07:14:45.656Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/e1/146505848fb86dc42add9ba26f6ce6caec9e37f28307e0fd17d3aa8beab3/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7625f0611499304b79136f16fb0cd9975812f49e26b8ddc2f28afb604c9bb6e3", size = 2204802, upload-time = "2026-03-30T07:14:47.459Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8f/78ca340a78ff3eb7ebcde89e618754e355a6d1e7096a72bb90ee593ed5a1/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c57d693e12222994f65c4ecd1e449abfc28cfb448244d163cd670014048191c", size = 1993394, upload-time = "2026-03-30T07:14:48.997Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/6c/e55b24d2ce596162ffdd0ca028ee95c85d04a92c3d8db090d7385809a07b/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:4ad0a568e441a171c0c9a52dfbe036fcec8b6e7883327a71d8d27def431a4219", size = 2095120, upload-time = "2026-03-30T07:14:50.554Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/66/262c6cc9d8e7494e303a0e00ef69ed4df298909072adbac8ee0db1635ecc/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ce9c6e6248e002ebb18b7fb30648db84c274fdd1ed6eba006e412cf0aff903f0", size = 2459723, upload-time = "2026-03-30T07:14:52.23Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/11/23a39aec759cab56919e06251659cc4f47685709aab12017c123656e86ce/qh3-1.7.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:df103e5d396420756eea86b8c7bd19ad1828c384db02569f8fba52b9fbe561d4", size = 2007184, upload-time = "2026-03-30T07:14:54.162Z" },
+]
+
[[package]]
name = "redis"
version = "7.2.0"
@@ -6121,6 +6326,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
+[[package]]
+name = "urllib3-future"
+version = "2.19.908"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "h11" },
+ { name = "jh2" },
+ { name = "qh3", marker = "(python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/0c/f0cb307d8d74d072db53256a9138872e6f0d47c2af5090478dce3466d28c/urllib3_future-2.19.908.tar.gz", hash = "sha256:0c149fbb186341a8ecdb53a005d01be342d87cf15fab28095be28955eb61c54f", size = 1157374, upload-time = "2026-04-14T04:55:01.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/4c/0a67028a0fece4e3f8bef29c93d0e0a293fab4fff56289111f9e7710410a/urllib3_future-2.19.908-py3-none-any.whl", hash = "sha256:6491641ff988db90aad3d376a32e7b4559e2cdd4932fd31a55a7cb0f66b19a4b", size = 723457, upload-time = "2026-04-14T04:55:00.291Z" },
+]
+
[[package]]
name = "uuid-utils"
version = "0.14.0"
@@ -6231,6 +6450,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" },
]
+[[package]]
+name = "wassima"
+version = "2.0.6"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/1d/e27f3b2730e1964e819d47ad1c7ffde135a3658b120a441ecc40be0c627d/wassima-2.0.6.tar.gz", hash = "sha256:7c7fa67161ebe0c0ffbbc4c648186de80124f62474682b57c3ac60520d5c471b", size = 145426, upload-time = "2026-04-07T01:52:02.559Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/10/bd9b185b33ecd2b2523eb83bb1088e6dfdc1dad19136d91312dce9996c37/wassima-2.0.6-py3-none-any.whl", hash = "sha256:24c327cfce58e36b1e554feb809a12cd6677e39158dee419deb0d16b8f648f0d", size = 140613, upload-time = "2026-04-07T01:52:00.835Z" },
+]
+
[[package]]
name = "wcwidth"
version = "0.6.0"