You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by yi...@apache.org on 2023/02/13 02:15:03 UTC

[skywalking-python] branch master updated: Add Httpx plugin (#283)

This is an automated email from the ASF dual-hosted git repository.

yihaochen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking-python.git


The following commit(s) were added to refs/heads/master by this push:
     new f9f3dcd  Add Httpx plugin (#283)
f9f3dcd is described below

commit f9f3dcd3e818e305c7f7cc971a293d7de050d831
Author: XinweiLyu <lx...@gmail.com>
AuthorDate: Sun Feb 12 21:14:56 2023 -0500

    Add Httpx plugin (#283)
---
 CHANGELOG.md                                    |   1 +
 docs/en/contribution/Developer.md               |   2 +
 docs/en/contribution/How-to-develop-plugin.md   |  29 ++---
 docs/en/contribution/How-to-test-locally.md     |   2 +
 docs/en/setup/Plugins.md                        |   1 +
 poetry.lock                                     | 106 ++++++++++++++----
 pyproject.toml                                  |   1 +
 skywalking/__init__.py                          |   1 +
 skywalking/plugins/sw_httpx.py                  | 102 ++++++++++++++++++
 tests/plugin/http/sw_httpx/__init__.py          |  16 +++
 tests/plugin/http/sw_httpx/docker-compose.yml   |  63 +++++++++++
 tests/plugin/http/sw_httpx/expected.data.yml    | 136 ++++++++++++++++++++++++
 tests/plugin/http/sw_httpx/services/__init__.py |  16 +++
 tests/plugin/http/sw_httpx/services/consumer.py |  39 +++++++
 tests/plugin/http/sw_httpx/services/provider.py |  32 ++++++
 tests/plugin/http/sw_httpx/test_httpx.py        |  36 +++++++
 16 files changed, 548 insertions(+), 35 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36e9c4c..8d7f963 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@
   - Add HBase plugin Python HappyBase model  (#266) 
   - Add FastAPI plugin websocket protocol support (#269)
   - Add Websockets (client) plugin (#269)
+  - Add HTTPX plugin (#283)
 
 - Fixes:
   - Allow RabbitMQ BlockingChannel.basic_consume() to link with outgoing spans (#224)
diff --git a/docs/en/contribution/Developer.md b/docs/en/contribution/Developer.md
index 132177d..3bf4781 100644
--- a/docs/en/contribution/Developer.md
+++ b/docs/en/contribution/Developer.md
@@ -25,6 +25,8 @@ We have migrated from basic pip to [Poetry](https://python-poetry.org/) to manag
 Once you have `make` ready, run `make env`, this will automatically install the right Poetry release, and create 
 (plus manage) a `.venv` virtual environment for us based on the currently activated Python 3 version. Enjoy coding!
 
+Note: Make sure you have `python3` aliased to `python` available on Windows computers instead of pointing to the Microsoft app store.
+
 ### Switching between Multiple Python Versions
 Do not develop/test on Python < 3.7, since Poetry and some other functionalities we implement rely on Python 3.7+
 
diff --git a/docs/en/contribution/How-to-develop-plugin.md b/docs/en/contribution/How-to-develop-plugin.md
index 4d78720..c1f52b5 100644
--- a/docs/en/contribution/How-to-develop-plugin.md
+++ b/docs/en/contribution/How-to-develop-plugin.md
@@ -3,24 +3,25 @@
 You can always take [the existing plugins](../setup/Plugins.md) as examples, while there are some general ideas for all plugins.
 1. A plugin is a module under the directory `skywalking/plugins` with an `install` method; 
 2. Inside the `install` method, you find out the relevant method(s) of the libraries that you plan to instrument, and create/close spans before/after those method(s).
-3. You should also provide version rules in the plugin module, which means the version of package your plugin support. You should init a dict with keys `name` and `rules`. the `name` is your plugin's corresponding package's name, the `rules` is the version rules this package should follow.
-   
-   You can use >, >=, ==, <=, <, and != operators in rules. 
-   
-   The relation between rules element in the rules array is **OR**, which means the version of the package should follow at least one rule in rules array.
-   
-   You can set many version rules in one element of rules array, separate each other with a space character, the relation of rules in one rule element is **AND**, which means the version of package should follow all rules in this rule element.
-   
-   For example, below `version_rule` indicates that the package version of `django` should `>=2.0 AND <=2.3 AND !=2.2.1` OR `>3.0`.
+3. You should also provide version rules in the plugin module, which means the version of package your plugin aim to test. 
+
+   All below variables will be used by the tools/plugin_doc_gen.py to produce a latest [Plugin Doc](../setup/Plugins.md).
+
    ```python
-   version_rule = {
-       "name": "django",
-       "rules": [">=2.0 <=2.3 !=2.2.1", ">3.0"]
+   link_vector = ['https://www.python-httpx.org/']  # This should link to the official website/doc of this lib
+   # The support matrix is for scenarios where some libraries don't work for certain Python versions
+   # Therefore, we use the matrix to instruct the CI testing pipeline to skip over plugin test for such Python version
+   # The right side versions, should almost always use A.B.* to test the latest minor version of two recent major versions. 
+   support_matrix = {
+       'httpx': {
+           '>=3.7': ['0.23.*', '0.22.*']
+       }
    }
+   # The note will be used when generating the plugin documentation for users.
+   note = """"""
    ```
 4. Every plugin requires a corresponding test under `tests/plugin` before it can be merged, refer to the [Plugin Test Guide](How-to-test-plugin.md) when writing a plugin test.
-5. Update the [Supported Plugin List](../setup/Plugins.md).
-6. Add the environment variables to [Environment Variable list](../setup/Configuration.md) if any.
+5. Add the corresponding configuration options added/modified by the new plugin to the config.py and add new comments for each, then regenerate the `configuration.md` by `make doc-gen`.
 
 ## Steps after coding
 
diff --git a/docs/en/contribution/How-to-test-locally.md b/docs/en/contribution/How-to-test-locally.md
index 7ab9fb4..43d0291 100644
--- a/docs/en/contribution/How-to-test-locally.md
+++ b/docs/en/contribution/How-to-test-locally.md
@@ -11,6 +11,8 @@ Please first refer to the [Developer Guide](Developer.md) to set up a developmen
 
 TL;DR: run ``make env``. This will create virtual environments for python and generate the protocol folder needed for the agent.
 
+Note: Make sure you have `python3` aliased to `python` available on Windows computers instead of pointing to the Microsoft app store.
+
 By now, you can do what you want. Let's get to the topic of how to test.
 
 The test process requires `docker` and `docker-compose` throughout. If you haven't installed them, please install them first.
diff --git a/docs/en/setup/Plugins.md b/docs/en/setup/Plugins.md
index 23df62e..4afa888 100644
--- a/docs/en/setup/Plugins.md
+++ b/docs/en/setup/Plugins.md
@@ -29,6 +29,7 @@ or a limitation of SkyWalking auto-instrumentation (welcome to contribute!)
 | [happybase](https://happybase.readthedocs.io) | Python >=3.7 - ['1.2.0'];  | `sw_happybase` |
 | [http_server](https://docs.python.org/3/library/http.server.html) | Python >=3.7 - ['*'];  | `sw_http_server` |
 | [werkzeug](https://werkzeug.palletsprojects.com/) | Python >=3.7 - ['1.0.1', '2.0'];  | `sw_http_server` |
+| [httpx](https://www.python-httpx.org/) | Python >=3.7 - ['0.23.*', '0.22.*'];  | `sw_httpx` |
 | [kafka-python](https://kafka-python.readthedocs.io) | Python >=3.7 - ['2.0'];  | `sw_kafka` |
 | [loguru](https://pypi.org/project/loguru/) | Python >=3.7 - ['0.6.0'];  | `sw_loguru` |
 | [mysqlclient](https://mysqlclient.readthedocs.io/) | Python >=3.7 - ['2.1.*'];  | `sw_mysqlclient` |
diff --git a/poetry.lock b/poetry.lock
index 114c102..88afb64 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2,14 +2,14 @@
 
 [[package]]
 name = "aiofiles"
-version = "22.1.0"
+version = "23.1.0"
 description = "File support for asyncio."
 category = "dev"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
-    {file = "aiofiles-22.1.0-py3-none-any.whl", hash = "sha256:1142fa8e80dbae46bb6339573ad4c8c0841358f79c6eb50a493dceca14621bad"},
-    {file = "aiofiles-22.1.0.tar.gz", hash = "sha256:9107f1ca0b2a5553987a94a3c9959fe5b491fdf731389aa5b7b1bd0733e32de6"},
+    {file = "aiofiles-23.1.0-py3-none-any.whl", hash = "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2"},
+    {file = "aiofiles-23.1.0.tar.gz", hash = "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635"},
 ]
 
 [[package]]
@@ -91,14 +91,14 @@ hiredis = ["hiredis (>=1.0)"]
 
 [[package]]
 name = "aiormq"
-version = "6.6.4"
+version = "6.7.1"
 description = "Pure python AMQP asynchronous client library"
 category = "dev"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
-    {file = "aiormq-6.6.4-py3-none-any.whl", hash = "sha256:9fb93dc871eb9c45a410e29669624790c01b70d27f20d512a22b146c411440ea"},
-    {file = "aiormq-6.6.4.tar.gz", hash = "sha256:95835a4db6117263305d450f838ccdc3eabd427c0deb32fd617769ad4c989e98"},
+    {file = "aiormq-6.7.1-py3-none-any.whl", hash = "sha256:c6de232c34c0be051a0251684fa480cb5ee498e9f536f244fba0668d06a7c8ed"},
+    {file = "aiormq-6.7.1.tar.gz", hash = "sha256:f0328da19ba47b9f8bcdb3eb80faa20a6acc195ba721f1ccc008754ddd0abaee"},
 ]
 
 [package.dependencies]
@@ -901,14 +901,14 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 
 [[package]]
 name = "flake8-docstrings"
-version = "1.6.0"
+version = "1.7.0"
 description = "Extension for flake8 which uses pydocstyle to check docstrings"
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.7"
 files = [
-    {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"},
-    {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"},
+    {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"},
+    {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"},
 ]
 
 [package.dependencies]
@@ -1302,6 +1302,28 @@ files = [
 six = "*"
 thriftpy2 = ">=0.4"
 
+[[package]]
+name = "httpcore"
+version = "0.16.3"
+description = "A minimal low-level HTTP client."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"},
+    {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"},
+]
+
+[package.dependencies]
+anyio = ">=3.0,<5.0"
+certifi = "*"
+h11 = ">=0.13,<0.15"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
 [[package]]
 name = "httptools"
 version = "0.5.0"
@@ -1356,6 +1378,30 @@ files = [
 [package.extras]
 test = ["Cython (>=0.29.24,<0.30.0)"]
 
+[[package]]
+name = "httpx"
+version = "0.23.3"
+description = "The next generation HTTP client."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"},
+    {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"},
+]
+
+[package.dependencies]
+certifi = "*"
+httpcore = ">=0.15.0,<0.17.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotli", "brotlicffi"]
+cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
 [[package]]
 name = "hug"
 version = "2.6.1"
@@ -2477,6 +2523,24 @@ urllib3 = ">=1.21.1,<1.27"
 socks = ["PySocks (>=1.5.6,!=1.5.7)"]
 use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
+    {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
+]
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
 [[package]]
 name = "sanic"
 version = "21.9.1"
@@ -2518,14 +2582,14 @@ files = [
 
 [[package]]
 name = "setuptools"
-version = "66.0.0"
+version = "67.2.0"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "setuptools-66.0.0-py3-none-any.whl", hash = "sha256:a78d01d1e2c175c474884671dde039962c9d74c7223db7369771fcf6e29ceeab"},
-    {file = "setuptools-66.0.0.tar.gz", hash = "sha256:bd6eb2d6722568de6d14b87c44a96fac54b2a45ff5e940e639979a3d1792adb6"},
+    {file = "setuptools-67.2.0-py3-none-any.whl", hash = "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c"},
+    {file = "setuptools-67.2.0.tar.gz", hash = "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48"},
 ]
 
 [package.extras]
@@ -2996,14 +3060,14 @@ testing = ["coverage", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist"]
 
 [[package]]
 name = "websocket-client"
-version = "1.4.2"
+version = "1.5.1"
 description = "WebSocket client for Python with low level API options"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "websocket-client-1.4.2.tar.gz", hash = "sha256:d6e8f90ca8e2dd4e8027c4561adeb9456b54044312dba655e7cae652ceb9ae59"},
-    {file = "websocket_client-1.4.2-py3-none-any.whl", hash = "sha256:d6b06432f184438d99ac1f456eaf22fe1ade524c3dd16e661142dc54e9cba574"},
+    {file = "websocket-client-1.5.1.tar.gz", hash = "sha256:3f09e6d8230892547132177f575a4e3e73cfdf06526e20cc02aa1c3b47184d40"},
+    {file = "websocket_client-1.5.1-py3-none-any.whl", hash = "sha256:cdf5877568b7e83aa7cf2244ab56a3213de587bbe0ce9d8b9600fc77b455d89e"},
 ]
 
 [package.extras]
@@ -3285,18 +3349,18 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
 
 [[package]]
 name = "zipp"
-version = "3.11.0"
+version = "3.13.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"},
-    {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"},
+    {file = "zipp-3.13.0-py3-none-any.whl", hash = "sha256:e8b2a36ea17df80ffe9e2c4fda3f693c3dad6df1697d3cd3af232db680950b0b"},
+    {file = "zipp-3.13.0.tar.gz", hash = "sha256:23f70e964bc11a34cef175bc90ba2914e1e4545ea1e3e2f67c079671883f9cb6"},
 ]
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
 
 [[package]]
@@ -3399,4 +3463,4 @@ kafka = ["kafka-python"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.7, <3.11"
-content-hash = "2b84493cca5e5189414dc6ded1c4a2e881a3bb20a01a8da4e28b5b1cac2d47c1"
+content-hash = "e5d6730b56dadeffa7355b287be1264c2925a142e958cff02ed3d8067ec9261c"
diff --git a/pyproject.toml b/pyproject.toml
index 1f21338..899f042 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -127,6 +127,7 @@ asyncpg = "^0.27.0"
 happybase = "1.2.0"
 websockets = "^10.4"
 loguru = "^0.6.0"
+httpx = "^0.23.3"
 
 [tool.poetry.group.lint.dependencies]
 flake8 = "^5.0.4"
diff --git a/skywalking/__init__.py b/skywalking/__init__.py
index 8cacb4f..75b5e27 100644
--- a/skywalking/__init__.py
+++ b/skywalking/__init__.py
@@ -51,6 +51,7 @@ class Component(Enum):
     AsyncPG = 7016
     AIORedis = 7017
     Websockets = 7018
+    HTTPX = 7019
 
 
 class Layer(Enum):
diff --git a/skywalking/plugins/sw_httpx.py b/skywalking/plugins/sw_httpx.py
new file mode 100644
index 0000000..2131b8f
--- /dev/null
+++ b/skywalking/plugins/sw_httpx.py
@@ -0,0 +1,102 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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 skywalking import Layer, Component, config
+from skywalking.trace.context import get_context, NoopContext
+from skywalking.trace.span import NoopSpan
+from skywalking.trace.tags import TagHttpMethod, TagHttpURL, TagHttpStatusCode
+
+link_vector = ['https://www.python-httpx.org/']
+support_matrix = {
+    'httpx': {
+        '>=3.7': ['0.23.*', '0.22.*']
+    }
+}
+note = """"""
+
+
+def install():
+    from httpx import _client
+    from httpx import USE_CLIENT_DEFAULT
+
+    _async_send = _client.AsyncClient.send
+    _send = _client.Client.send
+
+    async def _sw_async_send(self, request, *, stream=False, auth=USE_CLIENT_DEFAULT,
+                             follow_redirects=USE_CLIENT_DEFAULT, ):
+        url_object = request.url
+
+        span = NoopSpan(NoopContext()) if config.ignore_http_method_check(
+            request.method) else get_context().new_exit_span(op=url_object.path or '/', peer=url_object.netloc.decode(),
+                                                             component=Component.HTTPX)
+        with span:
+            carrier = span.inject()
+            span.layer = Layer.Http
+
+            if not request.headers:
+                request.headers = {}
+            for item in carrier:
+                request.headers[item.key] = item.val
+
+            span.tag(TagHttpMethod(request.method.upper()))
+            url_safe = str(url_object).replace(url_object.username, '').replace(url_object.password, '')
+
+            span.tag(TagHttpURL(url_safe))
+
+            res = await _async_send(self, request, stream=stream, auth=auth, follow_redirects=follow_redirects)
+
+            status_code = res.status_code
+            span.tag(TagHttpStatusCode(status_code))
+
+            if status_code >= 400:
+                span.error_occurred = True
+
+            return res
+
+    _client.AsyncClient.send = _sw_async_send
+
+    def _sw_send(self, request, *, stream=False, auth=USE_CLIENT_DEFAULT, follow_redirects=USE_CLIENT_DEFAULT, ):
+        url_object = request.url
+
+        span = NoopSpan(NoopContext()) if config.ignore_http_method_check(
+            request.method) else get_context().new_exit_span(op=url_object.path or '/', peer=url_object.netloc.decode(),
+                                                             component=Component.HTTPX)
+        with span:
+            carrier = span.inject()
+            span.layer = Layer.Http
+
+            if not request.headers:
+                request.headers = {}
+            for item in carrier:
+                request.headers[item.key] = item.val
+
+            span.tag(TagHttpMethod(request.method.upper()))
+            url_safe = str(url_object).replace(url_object.username, '').replace(url_object.password, '')
+
+            span.tag(TagHttpURL(url_safe))
+
+            res = _send(self, request, stream=stream, auth=auth, follow_redirects=follow_redirects)
+
+            status_code = res.status_code
+            span.tag(TagHttpStatusCode(status_code))
+
+            if status_code >= 400:
+                span.error_occurred = True
+
+            return res
+
+    _client.Client.send = _sw_send
diff --git a/tests/plugin/http/sw_httpx/__init__.py b/tests/plugin/http/sw_httpx/__init__.py
new file mode 100644
index 0000000..b1312a0
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/__init__.py
@@ -0,0 +1,16 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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/tests/plugin/http/sw_httpx/docker-compose.yml b/tests/plugin/http/sw_httpx/docker-compose.yml
new file mode 100644
index 0000000..d5ac43b
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/docker-compose.yml
@@ -0,0 +1,63 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+services:
+  collector:
+    extends:
+      service: collector
+      file: ../../docker-compose.base.yml
+
+  provider:
+    extends:
+      service: agent
+      file: ../../docker-compose.base.yml
+    ports:
+      - 9091:9091
+    volumes:
+      - .:/app
+    depends_on:
+      collector:
+        condition: service_healthy
+    command: ['bash', '-c', 'pip install fastapi uvicorn && pip install -r /app/requirements.txt && sw-python run python3 /app/services/provider.py']
+    healthcheck:
+      test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/9091"]
+      interval: 5s
+      timeout: 60s
+      retries: 120
+    environment:
+      SW_AGENT_SERVICE_NAME: provider
+      SW_AGENT_LOGGING_LEVEL: DEBUG
+
+  consumer:
+    extends:
+      service: agent
+      file: ../../docker-compose.base.yml
+    ports:
+      - 9090:9090
+    volumes:
+      - .:/app
+    command: ['bash', '-c', 'pip install fastapi uvicorn && pip install -r /app/requirements.txt && sw-python run python3 /app/services/consumer.py']
+    depends_on:
+      collector:
+        condition: service_healthy
+      provider:
+        condition: service_healthy
+    environment:
+      SW_AGENT_SERVICE_NAME: consumer
+      SW_AGENT_LOGGING_LEVEL: DEBUG
+
+networks:
+  beyond:
diff --git a/tests/plugin/http/sw_httpx/expected.data.yml b/tests/plugin/http/sw_httpx/expected.data.yml
new file mode 100644
index 0000000..7550471
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/expected.data.yml
@@ -0,0 +1,136 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+segmentItems:
+  - serviceName: provider
+    segmentSize: 2
+    segments:
+      - segmentId: not null
+        spans:
+          - operationName: /users
+            operationId: 0
+            parentSpanId: -1
+            spanId: 0
+            spanLayer: Http
+            tags:
+              - key: http.method
+                value: POST
+              - key: http.url
+                value: http://provider:9091/users
+              - key: http.status_code
+                value: '200'
+            refs:
+              - parentEndpoint: /users
+                networkAddress: provider:9091
+                refType: CrossProcess
+                parentSpanId: 1
+                parentTraceSegmentId: not null
+                parentServiceInstance: not null
+                parentService: consumer
+                traceId: not null
+            startTime: gt 0
+            endTime: gt 0
+            componentId: 7014
+            spanType: Entry
+            peer: not null
+            skipAnalysis: false
+      - segmentId: not null
+        spans:
+          - operationName: /users
+            operationId: 0
+            parentSpanId: -1
+            spanId: 0
+            spanLayer: Http
+            tags:
+              - key: http.method
+                value: POST
+              - key: http.url
+                value: http://provider:9091/users
+              - key: http.status_code
+                value: '200'
+            refs:
+              - parentEndpoint: /users
+                networkAddress: provider:9091
+                refType: CrossProcess
+                parentSpanId: 2
+                parentTraceSegmentId: not null
+                parentServiceInstance: not null
+                parentService: consumer
+                traceId: not null
+            startTime: gt 0
+            endTime: gt 0
+            componentId: 7014
+            spanType: Entry
+            peer: not null
+            skipAnalysis: false
+  - serviceName: consumer
+    segmentSize: 1
+    segments:
+      - segmentId: not null
+        spans:
+          - operationName: /users
+            parentSpanId: 0
+            spanId: 1
+            spanLayer: Http
+            tags:
+              - key: http.method
+                value: POST
+              - key: http.url
+                value: http://provider:9091/users
+              - key: http.status_code
+                value: '200'
+            startTime: gt 0
+            endTime: gt 0
+            componentId: 7019
+            spanType: Exit
+            peer: provider:9091
+            skipAnalysis: false
+          - operationName: /users
+            parentSpanId: 0
+            spanId: 2
+            spanLayer: Http
+            tags:
+              - key: http.method
+                value: POST
+              - key: http.url
+                value: http://provider:9091/users
+              - key: http.status_code
+                value: '200'
+            startTime: gt 0
+            endTime: gt 0
+            componentId: 7019
+            spanType: Exit
+            peer: provider:9091
+            skipAnalysis: false
+          - operationName: /users
+            operationId: 0
+            parentSpanId: -1
+            spanId: 0
+            spanLayer: Http
+            tags:
+              - key: http.method
+                value: POST
+              - key: http.url
+                value: http://0.0.0.0:9090/users
+              - key: http.status_code
+                value: '200'
+            startTime: gt 0
+            endTime: gt 0
+            componentId: 7014
+            spanType: Entry
+            peer: not null
+            skipAnalysis: false
diff --git a/tests/plugin/http/sw_httpx/services/__init__.py b/tests/plugin/http/sw_httpx/services/__init__.py
new file mode 100644
index 0000000..b1312a0
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/services/__init__.py
@@ -0,0 +1,16 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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/tests/plugin/http/sw_httpx/services/consumer.py b/tests/plugin/http/sw_httpx/services/consumer.py
new file mode 100644
index 0000000..b096ca1
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/services/consumer.py
@@ -0,0 +1,39 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import uvicorn
+from fastapi import FastAPI
+import httpx
+
+async_client = httpx.AsyncClient()
+client = httpx.Client()
+app = FastAPI()
+
+
+@app.post('/users')
+async def application():
+    try:
+        await async_client.post('http://provider:9091/users')
+        res = client.post('http://provider:9091/users')
+
+        return res.json()
+    except Exception:  # noqa
+        return {'message': 'Error'}
+
+
+if __name__ == '__main__':
+    uvicorn.run(app, host='0.0.0.0', port=9090)
diff --git a/tests/plugin/http/sw_httpx/services/provider.py b/tests/plugin/http/sw_httpx/services/provider.py
new file mode 100644
index 0000000..8b08399
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/services/provider.py
@@ -0,0 +1,32 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the 'License'); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import uvicorn
+from fastapi import FastAPI
+
+app = FastAPI()
+
+
+@app.post('/users')
+async def application():
+    try:
+        return {'song': 'Despacito', 'artist': 'Luis Fonsi'}
+    except Exception:  # noqa
+        return {'message': 'Error'}
+
+
+if __name__ == '__main__':
+    uvicorn.run(app, host='0.0.0.0', port=9091)
diff --git a/tests/plugin/http/sw_httpx/test_httpx.py b/tests/plugin/http/sw_httpx/test_httpx.py
new file mode 100644
index 0000000..3dd78c3
--- /dev/null
+++ b/tests/plugin/http/sw_httpx/test_httpx.py
@@ -0,0 +1,36 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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 typing import Callable
+
+import pytest
+import requests
+
+from skywalking.plugins.sw_httpx import support_matrix
+from tests.orchestrator import get_test_vector
+from tests.plugin.base import TestPluginBase
+
+
+@pytest.fixture
+def prepare():
+    # type: () -> Callable
+    return lambda *_: requests.post('http://0.0.0.0:9090/users', timeout=5)
+
+
+class TestPlugin(TestPluginBase):
+    @pytest.mark.parametrize('version', get_test_vector(lib_name='httpx', support_matrix=support_matrix))
+    def test_plugin(self, docker_compose, version):
+        self.validate()