diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 02ac077952..4e12e8f5bb 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -1,5 +1,8 @@ components: + instrumentation/opentelemetry-instrumentation-aerospike: + - kimsoungryoul + instrumentation/opentelemetry-instrumentation-aiohttp-client: - herin049 diff --git a/.github/workflows/core_contrib_test_0.yml b/.github/workflows/core_contrib_test_0.yml index 5f14b63ffa..da9e758767 100644 --- a/.github/workflows/core_contrib_test_0.yml +++ b/.github/workflows/core_contrib_test_0.yml @@ -563,6 +563,36 @@ jobs: - name: Run tests run: tox -e py39-test-instrumentation-aiopg -- -ra + py39-test-instrumentation-aerospike: + name: instrumentation-aerospike + 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.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py39-test-instrumentation-aerospike -- -ra + py39-test-instrumentation-aws-lambda: name: instrumentation-aws-lambda runs-on: ubuntu-latest diff --git a/.github/workflows/lint_0.yml b/.github/workflows/lint_0.yml index c165b8f67a..c19ce446c9 100644 --- a/.github/workflows/lint_0.yml +++ b/.github/workflows/lint_0.yml @@ -279,6 +279,25 @@ jobs: - name: Run tests run: tox -e lint-instrumentation-aiopg + lint-instrumentation-aerospike: + name: instrumentation-aerospike + 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 lint-instrumentation-aerospike + lint-instrumentation-aws-lambda: name: instrumentation-aws-lambda runs-on: ubuntu-latest diff --git a/.github/workflows/test_0.yml b/.github/workflows/test_0.yml index 781ed07e43..4204442fdf 100644 --- a/.github/workflows/test_0.yml +++ b/.github/workflows/test_0.yml @@ -2103,6 +2103,101 @@ jobs: - name: Run tests run: tox -e py313-test-instrumentation-aiopg -- -ra + py39-test-instrumentation-aerospike_ubuntu-latest: + name: instrumentation-aerospike 3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py39-test-instrumentation-aerospike -- -ra + + py310-test-instrumentation-aerospike_ubuntu-latest: + name: instrumentation-aerospike 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-aerospike -- -ra + + py311-test-instrumentation-aerospike_ubuntu-latest: + name: instrumentation-aerospike 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-aerospike -- -ra + + py312-test-instrumentation-aerospike_ubuntu-latest: + name: instrumentation-aerospike 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-aerospike -- -ra + + py313-test-instrumentation-aerospike_ubuntu-latest: + name: instrumentation-aerospike 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-aerospike -- -ra + py39-test-instrumentation-aws-lambda_ubuntu-latest: name: instrumentation-aws-lambda 3.9 Ubuntu runs-on: ubuntu-latest @@ -4686,98 +4781,3 @@ jobs: - name: Run tests run: tox -e py312-test-instrumentation-urllib3-0 -- -ra - - py312-test-instrumentation-urllib3-1_ubuntu-latest: - name: instrumentation-urllib3-1 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-urllib3-1 -- -ra - - py313-test-instrumentation-urllib3-0_ubuntu-latest: - name: instrumentation-urllib3-0 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-urllib3-0 -- -ra - - py313-test-instrumentation-urllib3-1_ubuntu-latest: - name: instrumentation-urllib3-1 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-urllib3-1 -- -ra - - pypy3-test-instrumentation-urllib3-0_ubuntu-latest: - name: instrumentation-urllib3-0 pypy-3.9 Ubuntu - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python pypy-3.9 - uses: actions/setup-python@v5 - with: - python-version: "pypy-3.9" - - - name: Install tox - run: pip install tox-uv - - - name: Run tests - run: tox -e pypy3-test-instrumentation-urllib3-0 -- -ra - - pypy3-test-instrumentation-urllib3-1_ubuntu-latest: - name: instrumentation-urllib3-1 pypy-3.9 Ubuntu - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python pypy-3.9 - uses: actions/setup-python@v5 - with: - python-version: "pypy-3.9" - - - name: Install tox - run: pip install tox-uv - - - name: Run tests - run: tox -e pypy3-test-instrumentation-urllib3-1 -- -ra diff --git a/.github/workflows/test_1.yml b/.github/workflows/test_1.yml index fedc742721..178480e1d6 100644 --- a/.github/workflows/test_1.yml +++ b/.github/workflows/test_1.yml @@ -32,6 +32,101 @@ env: jobs: + py312-test-instrumentation-urllib3-1_ubuntu-latest: + name: instrumentation-urllib3-1 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-urllib3-1 -- -ra + + py313-test-instrumentation-urllib3-0_ubuntu-latest: + name: instrumentation-urllib3-0 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-urllib3-0 -- -ra + + py313-test-instrumentation-urllib3-1_ubuntu-latest: + name: instrumentation-urllib3-1 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-urllib3-1 -- -ra + + pypy3-test-instrumentation-urllib3-0_ubuntu-latest: + name: instrumentation-urllib3-0 pypy-3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.9 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e pypy3-test-instrumentation-urllib3-0 -- -ra + + pypy3-test-instrumentation-urllib3-1_ubuntu-latest: + name: instrumentation-urllib3-1 pypy-3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.9 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e pypy3-test-instrumentation-urllib3-1 -- -ra + py39-test-instrumentation-requests_ubuntu-latest: name: instrumentation-requests 3.9 Ubuntu runs-on: ubuntu-latest @@ -4686,98 +4781,3 @@ jobs: - name: Run tests run: tox -e py310-test-propagator-aws-xray-1 -- -ra - - py311-test-propagator-aws-xray-0_ubuntu-latest: - name: propagator-aws-xray-0 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-propagator-aws-xray-0 -- -ra - - py311-test-propagator-aws-xray-1_ubuntu-latest: - name: propagator-aws-xray-1 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-propagator-aws-xray-1 -- -ra - - py312-test-propagator-aws-xray-0_ubuntu-latest: - name: propagator-aws-xray-0 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-propagator-aws-xray-0 -- -ra - - py312-test-propagator-aws-xray-1_ubuntu-latest: - name: propagator-aws-xray-1 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-propagator-aws-xray-1 -- -ra - - py313-test-propagator-aws-xray-0_ubuntu-latest: - name: propagator-aws-xray-0 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-propagator-aws-xray-0 -- -ra diff --git a/.github/workflows/test_2.yml b/.github/workflows/test_2.yml index 5fae9d5a84..634b33c38f 100644 --- a/.github/workflows/test_2.yml +++ b/.github/workflows/test_2.yml @@ -32,6 +32,101 @@ env: jobs: + py311-test-propagator-aws-xray-0_ubuntu-latest: + name: propagator-aws-xray-0 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-propagator-aws-xray-0 -- -ra + + py311-test-propagator-aws-xray-1_ubuntu-latest: + name: propagator-aws-xray-1 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-propagator-aws-xray-1 -- -ra + + py312-test-propagator-aws-xray-0_ubuntu-latest: + name: propagator-aws-xray-0 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-propagator-aws-xray-0 -- -ra + + py312-test-propagator-aws-xray-1_ubuntu-latest: + name: propagator-aws-xray-1 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-propagator-aws-xray-1 -- -ra + + py313-test-propagator-aws-xray-0_ubuntu-latest: + name: propagator-aws-xray-0 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-propagator-aws-xray-0 -- -ra + py313-test-propagator-aws-xray-1_ubuntu-latest: name: propagator-aws-xray-1 3.13 Ubuntu runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 1853b73414..77b2a5e3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New instrumentation for Aerospike database client (`opentelemetry-instrumentation-aerospike`) - `opentelemetry-instrumentation-asgi`: Add exemplars for `http.server.request.duration` and `http.server.duration` metrics ([#3739](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3739)) - `opentelemetry-instrumentation-wsgi`: Add exemplars for `http.server.request.duration` and `http.server.duration` metrics diff --git a/docs/instrumentation/aerospike/aerospike.rst b/docs/instrumentation/aerospike/aerospike.rst new file mode 100644 index 0000000000..54f0de54b5 --- /dev/null +++ b/docs/instrumentation/aerospike/aerospike.rst @@ -0,0 +1,10 @@ +.. include:: ../../../instrumentation/opentelemetry-instrumentation-aerospike/README.rst + :end-before: References + +Usage +----- + +.. automodule:: opentelemetry.instrumentation.aerospike + :members: + :undoc-members: + :show-inheritance: diff --git a/instrumentation/README.md b/instrumentation/README.md index 27008c0015..67057ce0b8 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -1,6 +1,7 @@ | Instrumentation | Supported Packages | Metrics support | Semconv status | | --------------- | ------------------ | --------------- | -------------- | +| [opentelemetry-instrumentation-aerospike](./opentelemetry-instrumentation-aerospike) | aerospike >= 17.0.0 | No | development | [opentelemetry-instrumentation-aio-pika](./opentelemetry-instrumentation-aio-pika) | aio_pika >= 7.2.0, < 10.0.0 | No | development | [opentelemetry-instrumentation-aiohttp-client](./opentelemetry-instrumentation-aiohttp-client) | aiohttp ~= 3.0 | Yes | migration | [opentelemetry-instrumentation-aiohttp-server](./opentelemetry-instrumentation-aiohttp-server) | aiohttp ~= 3.0 | Yes | migration diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/LICENSE b/instrumentation/opentelemetry-instrumentation-aerospike/LICENSE new file mode 100644 index 0000000000..19eae09deb --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/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 Support. 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 "page" as the copyright notice for easier identification within + third-party archives. + + Copyright The OpenTelemetry Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/README.rst b/instrumentation/opentelemetry-instrumentation-aerospike/README.rst new file mode 100644 index 0000000000..af510681ff --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/README.rst @@ -0,0 +1,97 @@ +OpenTelemetry Aerospike Instrumentation +======================================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-aerospike.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-aerospike/ + +This library allows tracing requests made by the Aerospike library. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-aerospike + +Requirements +------------ + +- Python >= 3.9 +- **aerospike >= 17.0.0** (minimum supported version) + +.. note:: + + This instrumentation only supports aerospike Python client version 17.0.0 and above. + Version 17.0.0 introduced significant API changes including removal of deprecated methods. + +Supported Operations +-------------------- + +The following Aerospike client methods are instrumented: + +- **Single Record Operations**: ``put``, ``get``, ``select``, ``exists``, ``remove``, ``touch``, ``operate``, ``append``, ``prepend``, ``increment`` +- **Batch Operations**: ``batch_read``, ``batch_write``, ``batch_operate``, ``batch_remove``, ``batch_apply`` +- **Query/Scan Operations**: ``query``, ``scan`` +- **UDF Operations**: ``apply``, ``scan_apply``, ``query_apply`` +- **Admin Operations**: ``truncate``, ``info_all`` + +Aerospike Client Version Compatibility +-------------------------------------- + +**Minimum Version: 17.0.0** + ++----------+--------------------------------------------------+ +| Version | Changes | ++==========+==================================================+ +| 17.0.0 | - Removed ``get_many()``, ``exists_many()``, | +| | ``select_many()`` (use ``batch_read()``) | +| | - Removed ``batch_get_ops()`` | +| | (use ``batch_operate()``) | +| | - Removed ``admin_query_user()``, | +| | ``admin_query_users()`` | ++----------+--------------------------------------------------+ +| 18.0.0 | - Added ``NamespaceNotFound`` exception | +| | - Added ``InvalidRequest`` exception | +| | - ``Query.where()/select()`` raises exception | +| | on duplicate calls | ++----------+--------------------------------------------------+ + +.. note:: + + Methods removed in version 17.0.0 are not supported by this instrumentation. + Use the replacement methods listed above. + +Captured Attributes +------------------- + +The instrumentation captures the following attributes: + +**Standard Attributes:** + +* ``db.system.name``: Always "aerospike". +* ``db.namespace``: Aerospike namespace. +* ``db.collection.name``: Aerospike set name. +* ``db.operation.name``: The operation being performed (e.g., "GET", "PUT", "QUERY"). +* ``db.user``: The user connected to the database (if configured). +* ``server.address``: The hostname or IP address of the Aerospike node. +* ``server.port``: The port of the Aerospike node. +* ``db.operation.batch.size``: Number of keys in batch operations. + +**Aerospike-Specific Attributes:** + +* ``db.aerospike.key``: The record key (only if ``capture_key=True`` is enabled). +* ``db.aerospike.bins``: List of bins being written or selected (for PUT/SELECT). +* ``db.aerospike.generation``: Record generation (from GET results). +* ``db.aerospike.ttl``: Record TTL (from GET results). +* ``db.aerospike.udf.module``: UDF module name. +* ``db.aerospike.udf.function``: UDF function name. + +References +---------- + +* `OpenTelemetry Aerospike Instrumentation `_ +* `OpenTelemetry Project `_ +* `Aerospike Python Client `_ +* `Aerospike Incompatible API Changes `_ diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aerospike/pyproject.toml new file mode 100644 index 0000000000..a47cf48403 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-aerospike" +dynamic = ["version"] +description = "OpenTelemetry Aerospike instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.9" +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.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api ~= 1.12", + "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-semantic-conventions == 0.61b0.dev", + "wrapt >= 1.12.1", +] + +[project.optional-dependencies] +instruments = [ + "aerospike >= 17.0.0", +] + +[project.entry-points.opentelemetry_instrumentor] +aerospike = "opentelemetry.instrumentation.aerospike:AerospikeInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-aerospike" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/aerospike/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py new file mode 100644 index 0000000000..dec9954690 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -0,0 +1,747 @@ +# 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. + +""" +Instrument `aerospike`_ to report Aerospike database queries. + +.. _aerospike: https://pypi.org/project/aerospike/ + + +Usage +----- + +The easiest way to instrument Aerospike clients is by calling +``AerospikeInstrumentor().instrument()``: + +.. code:: python + + import aerospike + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + # Instrument aerospike + AerospikeInstrumentor().instrument() + + config = {'hosts': [('127.0.0.1', 3000)]} + client = aerospike.client(config) + client.connect() + + # All subsequent operations will be traced + client.put(('test', 'demo', 'key1'), {'bin1': 'value1'}) + (key, meta, bins) = client.get(('test', 'demo', 'key1')) + +.. note:: + Calling the ``instrument`` method will wrap the ``aerospike.client()`` factory + function, so any client created after the ``instrument`` call will be instrumented. + + +Usage with Custom Tracer Provider +--------------------------------- + +You can optionally provide a custom tracer provider: + +.. code:: python + + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + import aerospike + + # Set up a custom tracer provider + provider = TracerProvider() + trace.set_tracer_provider(provider) + + # Instrument with custom tracer provider + AerospikeInstrumentor().instrument(tracer_provider=provider) + + config = {'hosts': [('127.0.0.1', 3000)]} + client = aerospike.client(config) + client.connect() + + +Request/Response Hooks +---------------------- + +You can customize span attributes using request, response, and error hooks: + +.. code:: python + + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + import aerospike + + def request_hook(span, operation, args, kwargs): + if span and span.is_recording(): + span.set_attribute("custom_user_attribute_from_request_hook", "some-value") + + def response_hook(span, operation, result): + if span and span.is_recording(): + span.set_attribute("custom_user_attribute_from_response_hook", "some-value") + + def error_hook(span, operation, exception): + if span and span.is_recording(): + span.set_attribute("aerospike.error.code", getattr(exception, 'code', -1)) + + # Instrument aerospike with hooks + AerospikeInstrumentor().instrument( + request_hook=request_hook, + response_hook=response_hook, + error_hook=error_hook, + ) + + config = {'hosts': [('127.0.0.1', 3000)]} + client = aerospike.client(config) + client.connect() + + # This will report a span with custom attributes + client.get(('test', 'demo', 'key1')) + + +Capture Record Keys +------------------- + +By default, record keys are not captured in span attributes for security reasons. +If you need to capture keys for debugging purposes, you can enable this feature: + +.. code:: python + + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + import aerospike + + # Enable key capture (use with caution) + AerospikeInstrumentor().instrument(capture_key=True) + + config = {'hosts': [('127.0.0.1', 3000)]} + client = aerospike.client(config) + client.connect() + + # The key 'user123' will be captured in the span as 'db.aerospike.key' + client.get(('test', 'users', 'user123')) + +.. warning:: + Enabling ``capture_key`` may expose sensitive data in your traces. + Only enable this option in development or when you are certain that + record keys do not contain personally identifiable information (PII) + or other sensitive data. + + +Suppress Instrumentation +------------------------ + +You can use the ``suppress_instrumentation`` context manager to prevent instrumentation +from being applied to specific Aerospike operations. This is useful when you want to avoid +creating spans for internal operations, health checks, or during specific code paths. + +.. code:: python + + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.utils import suppress_instrumentation + import aerospike + + # Instrument aerospike + AerospikeInstrumentor().instrument() + + config = {'hosts': [('127.0.0.1', 3000)]} + client = aerospike.client(config) + client.connect() + + # This will report a span + client.get(('test', 'demo', 'key1')) + + # This will NOT report a span + with suppress_instrumentation(): + client.get(('test', 'demo', 'internal-key')) + client.put(('test', 'demo', 'cache-key'), {'data': 'value'}) + + # This will report a span again + client.get(('test', 'demo', 'another-key')) + + +API +--- +""" + +from __future__ import annotations + +import functools +import logging +from collections.abc import Callable, Collection +from typing import Any + +from wrapt import wrap_function_wrapper + +from opentelemetry import trace +from opentelemetry.instrumentation.aerospike.package import _instruments +from opentelemetry.instrumentation.aerospike.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import ( + is_instrumentation_enabled, + unwrap, +) +from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer + +_logger = logging.getLogger(__name__) + +# Semantic convention constants +_DB_SYSTEM = "aerospike" +_DB_SYSTEM_ATTR = "db.system.name" +_DB_NAMESPACE_ATTR = "db.namespace" +_DB_COLLECTION_NAME_ATTR = "db.collection.name" +_DB_OPERATION_NAME_ATTR = "db.operation.name" +_DB_OPERATION_BATCH_SIZE_ATTR = "db.operation.batch.size" +_DB_RESPONSE_STATUS_CODE_ATTR = "db.response.status_code" +_SERVER_ADDRESS_ATTR = "server.address" +_SERVER_PORT_ATTR = "server.port" +_DB_USER_ATTR = "db.user" +_ERROR_TYPE_ATTR = "error.type" + +# Aerospike-specific attributes +_DB_AEROSPIKE_KEY_ATTR = "db.aerospike.key" +_DB_AEROSPIKE_GENERATION_ATTR = "db.aerospike.generation" +_DB_AEROSPIKE_TTL_ATTR = "db.aerospike.ttl" +_DB_AEROSPIKE_UDF_MODULE_ATTR = "db.aerospike.udf.module" +_DB_AEROSPIKE_UDF_FUNCTION_ATTR = "db.aerospike.udf.function" +_DB_AEROSPIKE_BINS_ATTR = "db.aerospike.bins" + + +class AerospikeInstrumentor(BaseInstrumentor): + """OpenTelemetry Aerospike Instrumentor.""" + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + tracer_provider = kwargs.get("tracer_provider") + tracer = trace.get_tracer( + __name__, + __version__, + tracer_provider, + schema_url="https://opentelemetry.io/schemas/1.28.0", + ) + + wrap_function_wrapper( + "aerospike", + "client", + _create_client_wrapper( + tracer, + kwargs.get("request_hook"), + kwargs.get("response_hook"), + kwargs.get("error_hook"), + kwargs.get("capture_key", False), + ), + ) + + def _uninstrument(self, **kwargs: Any) -> None: + """Remove instrumentation from Aerospike client factory.""" + import aerospike # noqa: PLC0415 # pylint: disable=import-outside-toplevel + + unwrap(aerospike, "client") + + +def _create_client_wrapper( + tracer: Tracer, + request_hook: Callable | None, + response_hook: Callable | None, + error_hook: Callable | None, + capture_key: bool, +) -> Callable: + def client_wrapper( + wrapped: Callable, instance: Any, args: tuple, kwargs: dict + ) -> Any: + client = wrapped(*args, **kwargs) + config = args[0] if args else kwargs.get("config") + return InstrumentedAerospikeClient( + client, + tracer, + request_hook, + response_hook, + error_hook, + capture_key, + config, + ) + + return client_wrapper + + +def _safe_call_hook(hook, *args): + """Call a hook safely, logging any exceptions.""" + if hook: + try: + hook(*args) + except Exception: # pylint: disable=broad-exception-caught + _logger.exception("Error executing hook") + + +def _traced_method( + tracer: Tracer, + method: Callable, + operation: str, + extract_ns_set: Callable[[tuple], tuple[str | None, str | None]], + client_instance: InstrumentedAerospikeClient, + set_extra_attrs: Callable[[Span, tuple], None] | None = None, + set_result_attrs: Callable[[Span, Any], None] | None = None, +) -> Callable: + """Generic wrapper for tracing Aerospike methods.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + namespace, set_name = extract_ns_set(args) + span_name = _generate_span_name(operation, namespace, set_name) + + with tracer.start_as_current_span( + span_name, kind=SpanKind.CLIENT + ) as span: + if span.is_recording(): + span.set_attribute(_DB_SYSTEM_ATTR, _DB_SYSTEM) + if namespace: + span.set_attribute(_DB_NAMESPACE_ATTR, namespace) + if set_name: + span.set_attribute(_DB_COLLECTION_NAME_ATTR, set_name) + span.set_attribute(_DB_OPERATION_NAME_ATTR, operation) + client_instance._set_connection_attributes(span) # noqa: SLF001 + + if set_extra_attrs: + set_extra_attrs(span, args) + + _safe_call_hook( + client_instance._request_hook, # noqa: SLF001 + span, + operation, + args, + kwargs, + ) + + try: + result = method(*args, **kwargs) + _safe_call_hook( + client_instance._response_hook, # noqa: SLF001 + span, + operation, + result, + ) + if set_result_attrs and span.is_recording(): + set_result_attrs(span, result) + return result + except Exception as exc: + if span.is_recording(): + _set_error_attributes(span, exc) + _safe_call_hook( + client_instance._error_hook, # noqa: SLF001 + span, + operation, + exc, + ) + raise + + return wrapper + + +class InstrumentedAerospikeClient: + """Instrumented wrapper for Aerospike Client.""" + + def __init__( + self, + client: Any, + tracer: Tracer, + request_hook: Callable | None, + response_hook: Callable | None, + error_hook: Callable | None, + capture_key: bool, + config: dict | None = None, + ): + self._client = client + self._tracer = tracer + self._request_hook = request_hook + self._response_hook = response_hook + self._error_hook = error_hook + self._capture_key = capture_key + self._server_address = None + self._server_port = None + self._user = None + + if config and isinstance(config, dict): + hosts = config.get("hosts", []) + if hosts: + try: + host = hosts[0] + if isinstance(host, tuple): + self._server_address = str(host[0]) + self._server_port = ( + int(host[1]) if len(host) > 1 else 3000 + ) + except (TypeError, AttributeError, IndexError): + pass + + if "user" in config: + self._user = str(config["user"]) + elif "username" in config: + self._user = str(config["username"]) + + # Configuration for method instrumentation: + # method_name: (operation_name, extractor_func, extra_attrs_func, result_attrs_func) + self._method_config = {} + self._init_method_config() + + def _init_method_config(self): + # Single record operations + for method in [ + "get", + "exists", + "remove", + "touch", + "operate", + "append", + "prepend", + "increment", + ]: + self._method_config[method] = ( + method.upper(), + _ns_set_from_key_arg, + self._extra_attrs_capture_key, + _set_result_attributes if method == "get" else None, + ) + + # Operations with bins + for method in ["put", "select"]: + self._method_config[method] = ( + method.upper(), + _ns_set_from_key_arg, + self._extra_attrs_bins, + None, + ) + + # Batch operations + for method in [ + "batch_read", + "batch_write", + "batch_operate", + "batch_remove", + "batch_apply", + ]: + self._method_config[method] = ( + _get_batch_operation_name(method), + _ns_set_from_batch_arg, + _extra_attrs_batch_size, + None, + ) + + # UDF operations + self._method_config["apply"] = ( + "APPLY", + _ns_set_from_key_arg, + self._extra_attrs_udf_apply, + None, + ) + self._method_config["scan_apply"] = ( + "SCAN APPLY", + _ns_set_from_positional_args, + _extra_attrs_scan_apply_udf, + None, + ) + self._method_config["query_apply"] = ( + "QUERY APPLY", + _ns_set_from_positional_args, + _extra_attrs_query_apply_udf, + None, + ) + + # Admin/Info operations + self._method_config["truncate"] = ( + "TRUNCATE", + _ns_set_from_admin_args, + None, + None, + ) + self._method_config["info_all"] = ( + "INFO_ALL", + _ns_set_noop, + None, + None, + ) + + def __getattr__(self, name: str) -> Any: + attr = getattr(self._client, name) + if not callable(attr): + return attr + + if name in self._method_config: + op_name, extractor, extra_attrs, res_attrs = self._method_config[ + name + ] + wrapped = _traced_method( + self._tracer, + attr, + op_name, + extractor, + self, + extra_attrs, + res_attrs, + ) + object.__setattr__(self, name, wrapped) + return wrapped + + if name in ("query", "scan"): + return self._create_query_scan_factory(attr, name.upper()) + + return attr + + def connect(self, *args, **kwargs) -> InstrumentedAerospikeClient: + self._client.connect(*args, **kwargs) + self._update_server_info_from_nodes() + return self + + def _update_server_info_from_nodes(self) -> None: + try: + if not hasattr(self._client, "get_nodes"): + return + nodes = self._client.get_nodes() + if not nodes: + return + node = nodes[0] + if isinstance(node, tuple) and len(node) >= 2: + self._server_address = str(node[0]) + self._server_port = int(node[1]) + elif hasattr(node, "name"): + host, port = _parse_host_port(str(node.name)) + if host: + self._server_address = host + self._server_port = port or 3000 + except (TypeError, AttributeError, IndexError, ValueError): + pass + + def close(self) -> None: + self._client.close() + + def is_connected(self) -> bool: + return self._client.is_connected() + + def _set_connection_attributes(self, span: Span) -> None: + if self._server_address: + span.set_attribute(_SERVER_ADDRESS_ATTR, self._server_address) + if self._server_port: + span.set_attribute(_SERVER_PORT_ATTR, self._server_port) + if self._user: + span.set_attribute(_DB_USER_ATTR, self._user) + + def _create_query_scan_factory( + self, method: Callable, operation: str + ) -> Callable: + @functools.wraps(method) + def wrapper(*args, **kwargs): + query_scan_obj = method(*args, **kwargs) + namespace, set_name = _ns_set_from_positional_args(args) + return InstrumentedQueryScan( + query_scan_obj, operation, namespace, set_name, self + ) + + return wrapper + + def _extra_attrs_capture_key(self, span: Span, args: tuple) -> None: + if not self._capture_key: + return + key_tuple = args[0] if args else None + if ( + key_tuple + and isinstance(key_tuple, tuple) + and len(key_tuple) > 2 + and key_tuple[2] is not None + ): + span.set_attribute(_DB_AEROSPIKE_KEY_ATTR, str(key_tuple[2])) + + def _extra_attrs_bins(self, span: Span, args: tuple) -> None: + self._extra_attrs_capture_key(span, args) + if len(args) > 1: + bins = args[1] + if isinstance(bins, dict): + span.set_attribute(_DB_AEROSPIKE_BINS_ATTR, list(bins.keys())) + elif isinstance(bins, (list, tuple)): + span.set_attribute(_DB_AEROSPIKE_BINS_ATTR, list(bins)) + + def _extra_attrs_udf_apply(self, span: Span, args: tuple) -> None: + if len(args) > 1: + span.set_attribute(_DB_AEROSPIKE_UDF_MODULE_ATTR, str(args[1])) + if len(args) > 2: + span.set_attribute(_DB_AEROSPIKE_UDF_FUNCTION_ATTR, str(args[2])) + self._extra_attrs_capture_key(span, args) + + +class InstrumentedQueryScan: + """Instrumented wrapper for Aerospike Query/Scan objects.""" + + _EXECUTION_METHODS = frozenset( + {"results", "foreach", "execute_background"} + ) + + def __init__( + self, + query_scan_obj: Any, + operation: str, + namespace: str | None, + set_name: str | None, + client: InstrumentedAerospikeClient, + ): + self._inner = query_scan_obj + self._operation = operation + self._namespace = namespace + self._set_name = set_name + self._client = client + + def __getattr__(self, name: str) -> Any: + attr = getattr(self._inner, name) + if not callable(attr): + return attr + + if name in self._EXECUTION_METHODS: + wrapped = _traced_method( + self._client._tracer, # noqa: SLF001 + attr, + self._operation, + lambda args: (self._namespace, self._set_name), + self._client, + ) + object.__setattr__(self, name, wrapped) + return wrapped + + # Chaining support for config methods + @functools.wraps(attr) + def chaining_wrapper(*args, **kwargs): + result = attr(*args, **kwargs) + return self if result is self._inner else result + + return chaining_wrapper + + +# -- Helper Functions -- + + +def _ns_set_from_key_arg(args: tuple) -> tuple[str | None, str | None]: + key_tuple = args[0] if args and isinstance(args[0], tuple) else None + if not key_tuple: + return None, None + return key_tuple[0], key_tuple[1] if len(key_tuple) > 1 else None + + +def _ns_set_from_batch_arg(args: tuple) -> tuple[str | None, str | None]: + keys = args[0] if args and isinstance(args[0], (list, tuple)) else None + if not keys: + return None, None + first_key = keys[0] + if isinstance(first_key, tuple) and len(first_key) >= 2: + return first_key[0], first_key[1] + return None, None + + +def _ns_set_from_positional_args( + args: tuple, +) -> tuple[str | None, str | None]: + namespace = args[0] if args else None + set_name = args[1] if len(args) > 1 else None + return namespace, set_name + + +def _ns_set_from_admin_args(args: tuple) -> tuple[str | None, str | None]: + namespace = args[0] if args and isinstance(args[0], str) else None + set_name = args[1] if len(args) > 1 and isinstance(args[1], str) else None + return namespace, set_name + + +def _ns_set_noop(args: tuple) -> tuple[str | None, str | None]: + return None, None + + +def _extra_attrs_batch_size(span: Span, args: tuple) -> None: + keys = args[0] if args and isinstance(args[0], (list, tuple)) else None + if keys: + span.set_attribute(_DB_OPERATION_BATCH_SIZE_ATTR, len(keys)) + + +def _extra_attrs_scan_apply_udf(span: Span, args: tuple) -> None: + if len(args) > 2: + span.set_attribute(_DB_AEROSPIKE_UDF_MODULE_ATTR, str(args[2])) + if len(args) > 3: + span.set_attribute(_DB_AEROSPIKE_UDF_FUNCTION_ATTR, str(args[3])) + + +def _extra_attrs_query_apply_udf(span: Span, args: tuple) -> None: + if len(args) > 3: + span.set_attribute(_DB_AEROSPIKE_UDF_MODULE_ATTR, str(args[3])) + if len(args) > 4: + span.set_attribute(_DB_AEROSPIKE_UDF_FUNCTION_ATTR, str(args[4])) + + +def _get_batch_operation_name(method: str) -> str: + method_upper = method.upper() + if method_upper.startswith("BATCH_"): + return f"BATCH {method_upper[6:]}" + return f"BATCH {method_upper}" + + +def _parse_host_port(address: str) -> tuple[str | None, int | None]: + if not address: + return None, None + host, port = address, None + if address.startswith("["): + bracket_end = address.find("]") + if bracket_end >= 0: + host = address[1:bracket_end] + rest = address[bracket_end + 1 :] + if rest.startswith(":"): + try: + port = int(rest[1:]) + except ValueError: + pass + elif address.count(":") == 1: + host, port_str = address.split(":", 1) + try: + port = int(port_str) + except ValueError: + pass + return host, port + + +def _generate_span_name( + operation: str, namespace: str | None, set_name: str | None +) -> str: + if namespace and set_name: + return f"{operation} {namespace}.{set_name}" + if namespace: + return f"{operation} {namespace}" + return operation + + +def _set_result_attributes(span: Span, result: Any) -> None: + if isinstance(result, tuple) and len(result) >= 2: + meta = result[1] + if isinstance(meta, dict): + if "gen" in meta: + span.set_attribute(_DB_AEROSPIKE_GENERATION_ATTR, meta["gen"]) + if "ttl" in meta: + span.set_attribute(_DB_AEROSPIKE_TTL_ATTR, meta["ttl"]) + + +def _set_error_attributes(span: Span, exc: Exception) -> None: + span.set_status(Status(StatusCode.ERROR, str(exc))) + span.set_attribute(_ERROR_TYPE_ATTR, type(exc).__name__) + if hasattr(exc, "code"): + span.set_attribute(_DB_RESPONSE_STATUS_CODE_ATTR, str(exc.code)) + + +# Public API +__all__ = [ + "AerospikeInstrumentor", + "InstrumentedAerospikeClient", + "InstrumentedQueryScan", + "__version__", +] diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/package.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/package.py new file mode 100644 index 0000000000..06b0325c8c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/package.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. + +_instruments = ("aerospike >= 17.0.0",) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/version.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/version.py new file mode 100644 index 0000000000..c099e9440e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/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.61b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-aerospike/test-requirements.txt new file mode 100644 index 0000000000..8f58161558 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/test-requirements.txt @@ -0,0 +1,4 @@ +pytest==7.4.4 +aerospike >= 17.0.0 +-e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-aerospike diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py new file mode 100644 index 0000000000..cd13872ce6 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -0,0 +1,1015 @@ +# 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=import-outside-toplevel,no-self-use,redefined-outer-name,unused-variable,too-many-lines +# ruff: noqa: PLC0415 + +"""Unit tests for OpenTelemetry Aerospike Instrumentation.""" + +from unittest import mock +from unittest.mock import MagicMock, patch + +from opentelemetry import trace +from opentelemetry.instrumentation.utils import suppress_instrumentation +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import SpanKind, StatusCode + + +class TestAerospikeInstrumentation(TestBase): # pylint: disable=too-many-public-methods + """Unit tests using TestBase for consistent test infrastructure.""" + + instrumentor = None # Will be set in _instrument() + + def setUp(self): + super().setUp() + self.mock_aerospike = MagicMock() + self.mock_client = MagicMock() + self.mock_aerospike.client.return_value = self.mock_client + + def _instrument(self, **kwargs): + """Helper to instrument with mocked aerospike.""" + with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + + self.instrumentor = AerospikeInstrumentor() + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, **kwargs + ) + + def _uninstrument(self): + """Helper to uninstrument.""" + with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): + self.instrumentor.uninstrument() + + def test_instrument_uninstrument(self): + """Test instrument and uninstrument cycle.""" + self._instrument() + + # Verify client function was wrapped + self.assertTrue(hasattr(self.mock_aerospike.client, "__wrapped__")) + + self._uninstrument() + + # Verify client function was unwrapped + self.assertFalse(hasattr(self.mock_aerospike.client, "__wrapped__")) + + def test_span_properties(self): + """Test that spans have correct properties.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "GET test.demo") + self.assertEqual(span.kind, SpanKind.CLIENT) + self.assertEqual(span.attributes["db.system.name"], "aerospike") + self.assertEqual(span.attributes["db.namespace"], "test") + self.assertEqual(span.attributes["db.collection.name"], "demo") + self.assertEqual(span.attributes["db.operation.name"], "GET") + finally: + self._uninstrument() + + def test_not_recording(self): + """Test that attributes are not set when span is not recording.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + + mock_tracer = mock.Mock() + mock_span = mock.Mock() + mock_span.is_recording.return_value = False + mock_tracer.start_as_current_span.return_value.__enter__ = mock.Mock( + return_value=mock_span + ) + mock_tracer.start_as_current_span.return_value.__exit__ = mock.Mock( + return_value=False + ) + + with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): + with mock.patch("opentelemetry.trace.get_tracer") as tracer: + tracer.return_value = mock_tracer + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument() + + try: + client = self.mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + finally: + instrumentor.uninstrument() + + def test_suppress_instrumentation(self): + """Test that instrumentation can be suppressed.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + self._instrument() + + try: + client = self.mock_aerospike.client({}) + + # Execute with suppression + with suppress_instrumentation(): + client.get(("test", "demo", "key1")) + + # No spans should be created + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + + # Verify instrumentation works again after exiting context + client.get(("test", "demo", "key1")) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + finally: + self._uninstrument() + + def test_error_sets_span_status(self): + """Test that errors set span status correctly.""" + error = ValueError("Record not found") + error.code = 2 # KEY_NOT_FOUND + self.mock_client.get.side_effect = error + self._instrument() + + try: + client = self.mock_aerospike.client({}) + + with self.assertRaises(ValueError): + client.get(("test", "demo", "key1")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertEqual(span.attributes["error.type"], "ValueError") + self.assertEqual(span.attributes["db.response.status_code"], "2") + finally: + self._uninstrument() + + def test_batch_operation_includes_size(self): + """Test that batch operations include batch size attribute.""" + self.mock_client.batch_read.return_value = [] + self._instrument() + + try: + client = self.mock_aerospike.client({}) + + keys = [ + ("test", "demo", "key1"), + ("test", "demo", "key2"), + ("test", "demo", "key3"), + ] + client.batch_read(keys) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.attributes["db.operation.batch.size"], 3) + finally: + self._uninstrument() + + def test_request_hook(self): + """Test that request hook is called.""" + self.mock_client.put.return_value = None + hook_calls = [] + + def request_hook(span, operation, args, kwargs): + hook_calls.append({"operation": operation}) + span.set_attribute("custom.attr", "test") + + self._instrument(request_hook=request_hook) + + try: + client = self.mock_aerospike.client({}) + client.put(("test", "demo", "key1"), {"bin": "value"}) + + self.assertEqual(len(hook_calls), 1) + self.assertEqual(hook_calls[0]["operation"], "PUT") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(spans[0].attributes.get("custom.attr"), "test") + finally: + self._uninstrument() + + def test_response_hook(self): + """Test that response hook is called.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1}, + {"bin": "value"}, + ) + hook_calls = [] + + def response_hook(span, operation, result): + hook_calls.append({"operation": operation, "result": result}) + + self._instrument(response_hook=response_hook) + + try: + client = self.mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + self.assertEqual(len(hook_calls), 1) + self.assertEqual(hook_calls[0]["operation"], "GET") + finally: + self._uninstrument() + + def test_error_hook(self): + """Test that error hook is called on exception.""" + self.mock_client.get.side_effect = RuntimeError("Test error") + hook_calls = [] + + def error_hook(span, operation, exception): + hook_calls.append( + {"operation": operation, "error": str(exception)} + ) + + self._instrument(error_hook=error_hook) + + try: + client = self.mock_aerospike.client({}) + + with self.assertRaises(RuntimeError): + client.get(("test", "demo", "key1")) + + self.assertEqual(len(hook_calls), 1) + self.assertEqual(hook_calls[0]["operation"], "GET") + self.assertIn("Test error", hook_calls[0]["error"]) + finally: + self._uninstrument() + + def test_capture_key_enabled(self): + """Test that key is captured when enabled.""" + self.mock_client.put.return_value = None + self._instrument(capture_key=True) + + try: + client = self.mock_aerospike.client({}) + client.put(("test", "demo", "my_key"), {"bin": "value"}) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual( + spans[0].attributes.get("db.aerospike.key"), "my_key" + ) + finally: + self._uninstrument() + + def test_capture_key_disabled_by_default(self): + """Test that key is not captured by default.""" + self.mock_client.put.return_value = None + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.put(("test", "demo", "secret_key"), {"bin": "value"}) + + spans = self.memory_exporter.get_finished_spans() + self.assertNotIn("db.aerospike.key", spans[0].attributes) + finally: + self._uninstrument() + + def test_no_op_tracer_provider(self): + """Test with NoOpTracerProvider.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + + with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + + tracer_provider = trace.NoOpTracerProvider() + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + + try: + client = self.mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + finally: + instrumentor.uninstrument() + + def test_instrumentation_dependencies(self): + """Test that dependencies are correctly specified.""" + with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + + instrumentor = AerospikeInstrumentor() + deps = instrumentor.instrumentation_dependencies() + + self.assertEqual(len(deps), 1) + self.assertIn("aerospike >= 17.0.0", deps[0]) + + def test_proxy_passthrough(self): + """Test that non-instrumented methods pass through.""" + self.mock_client.is_connected.return_value = True + self.mock_client.some_other_method.return_value = "result" + self._instrument() + + try: + client = self.mock_aerospike.client({}) + + client.connect() + self.mock_client.connect.assert_called_once() + + self.assertTrue(client.is_connected()) + + client.close() + self.mock_client.close.assert_called_once() + finally: + self._uninstrument() + + def test_query_factory_no_span(self): + """Test that client.query() factory alone creates no span.""" + mock_query = MagicMock() + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.query("test", "users") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + finally: + self._uninstrument() + + def test_query_results_creates_span(self): + """Test that query.results() creates the correct span.""" + mock_query = MagicMock() + mock_query.results.return_value = [] + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + query = client.query("test", "users") + query.results() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "QUERY test.users") + self.assertEqual(span.attributes["db.operation.name"], "QUERY") + self.assertEqual(span.attributes["db.namespace"], "test") + self.assertEqual(span.attributes["db.collection.name"], "users") + finally: + self._uninstrument() + + def test_query_foreach_creates_span(self): + """Test that query.foreach() creates the correct span.""" + mock_query = MagicMock() + mock_query.foreach.return_value = None + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + query = client.query("test", "users") + query.foreach(lambda rec: None) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "QUERY test.users") + self.assertEqual(span.attributes["db.operation.name"], "QUERY") + finally: + self._uninstrument() + + def test_query_config_methods_passthrough(self): + """Test that config methods (select, where) pass through without span.""" + mock_query = MagicMock() + mock_query.select.return_value = None + mock_query.where.return_value = None + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + query = client.query("test", "users") + query.select("bin1", "bin2") + query.where(MagicMock()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + + # Verify calls were forwarded to the inner object + mock_query.select.assert_called_once_with("bin1", "bin2") + mock_query.where.assert_called_once() + finally: + self._uninstrument() + + def test_query_results_with_error(self): + """Test that errors during query.results() set span error status.""" + mock_query = MagicMock() + error = RuntimeError("Query failed") + mock_query.results.side_effect = error + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + query = client.query("test", "users") + + with self.assertRaises(RuntimeError): + query.results() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertEqual(span.attributes["error.type"], "RuntimeError") + finally: + self._uninstrument() + + def test_query_results_hooks(self): + """Test that request/response/error hooks work with query.results().""" + mock_query = MagicMock() + mock_query.results.return_value = [("key", "meta", "bins")] + self.mock_client.query.return_value = mock_query + + request_calls = [] + response_calls = [] + + def request_hook(span, operation, args, kwargs): + request_calls.append(operation) + + def response_hook(span, operation, result): + response_calls.append(operation) + + self._instrument( + request_hook=request_hook, response_hook=response_hook + ) + + try: + client = self.mock_aerospike.client({}) + query = client.query("test", "users") + query.results() + + self.assertEqual(len(request_calls), 1) + self.assertEqual(request_calls[0], "QUERY") + self.assertEqual(len(response_calls), 1) + self.assertEqual(response_calls[0], "QUERY") + finally: + self._uninstrument() + + def test_query_suppress_instrumentation(self): + """Test that suppress_instrumentation prevents query span creation.""" + mock_query = MagicMock() + mock_query.results.return_value = [] + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + query = client.query("test", "users") + + with suppress_instrumentation(): + query.results() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + finally: + self._uninstrument() + + def test_query_method_chaining(self): + """Test that chaining config methods preserves instrumentation.""" + mock_query = MagicMock() + # select() returns the inner query object (self-chaining pattern) + mock_query.select.return_value = mock_query + mock_query.where.return_value = mock_query + mock_query.results.return_value = [("key", "meta", "bins")] + self.mock_client.query.return_value = mock_query + self._instrument() + + try: + client = self.mock_aerospike.client({}) + # This chain must produce a span on .results() + client.query("test", "users").select("bin1").where( + MagicMock() + ).results() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "QUERY test.users") + self.assertEqual(span.attributes["db.operation.name"], "QUERY") + finally: + self._uninstrument() + + def test_udf_operation_captures_module_function(self): + """Test UDF operation captures module and function names.""" + self.mock_client.apply.return_value = "result" + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.apply( + ("test", "demo", "key1"), + "mymodule", + "myfunction", + ["arg1"], + ) + + spans = self.memory_exporter.get_finished_spans() + span = spans[0] + + self.assertEqual(span.name, "APPLY test.demo") + self.assertEqual( + span.attributes.get("db.aerospike.udf.module"), "mymodule" + ) + self.assertEqual( + span.attributes.get("db.aerospike.udf.function"), "myfunction" + ) + finally: + self._uninstrument() + + def test_result_metadata_captured(self): + """Test that generation and TTL are captured from results.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 5, "ttl": 3600}, + {"bin": "value"}, + ) + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + spans = self.memory_exporter.get_finished_spans() + span = spans[0] + + self.assertEqual(span.attributes.get("db.aerospike.generation"), 5) + self.assertEqual(span.attributes.get("db.aerospike.ttl"), 3600) + finally: + self._uninstrument() + + def test_span_name_format(self): + """Test span name follows {operation} {namespace}.{set} format.""" + self.mock_client.put.return_value = None + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.put(("production", "orders", "order123"), {"total": 100}) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(spans[0].name, "PUT production.orders") + finally: + self._uninstrument() + + def test_span_name_without_set(self): + """Test span name when set is None.""" + self.mock_client.put.return_value = None + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.put(("test", None, "key1"), {"bin": "value"}) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(spans[0].name, "PUT test") + finally: + self._uninstrument() + + def test_scan_apply_creates_correct_span(self): + """Test scan_apply uses namespace/set args, not key_tuple.""" + self.mock_client.scan_apply.return_value = 12345 + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.scan_apply("test", "demo", "mymodule", "myfunc", ["arg1"]) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "SCAN APPLY test.demo") + self.assertEqual(span.attributes["db.namespace"], "test") + self.assertEqual(span.attributes["db.collection.name"], "demo") + self.assertEqual( + span.attributes["db.operation.name"], "SCAN APPLY" + ) + self.assertEqual( + span.attributes["db.aerospike.udf.module"], "mymodule" + ) + self.assertEqual( + span.attributes["db.aerospike.udf.function"], "myfunc" + ) + finally: + self._uninstrument() + + def test_query_apply_creates_correct_span(self): + """Test query_apply uses namespace/set args with predicate offset.""" + self.mock_client.query_apply.return_value = 67890 + self._instrument() + + try: + client = self.mock_aerospike.client({}) + predicate = MagicMock() + client.query_apply( + "test", "demo", predicate, "mymodule", "myfunc", ["arg1"] + ) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "QUERY APPLY test.demo") + self.assertEqual(span.attributes["db.namespace"], "test") + self.assertEqual(span.attributes["db.collection.name"], "demo") + self.assertEqual( + span.attributes["db.operation.name"], "QUERY APPLY" + ) + self.assertEqual( + span.attributes["db.aerospike.udf.module"], "mymodule" + ) + self.assertEqual( + span.attributes["db.aerospike.udf.function"], "myfunc" + ) + finally: + self._uninstrument() + + def test_scan_results_creates_span(self): + """Test that scan.results() creates the correct span.""" + mock_scan = MagicMock() + mock_scan.results.return_value = [] + self.mock_client.scan.return_value = mock_scan + self._instrument() + + try: + client = self.mock_aerospike.client({}) + scan = client.scan("test", "demo") + scan.results() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "SCAN test.demo") + self.assertEqual(span.attributes["db.operation.name"], "SCAN") + self.assertEqual(span.attributes["db.namespace"], "test") + self.assertEqual(span.attributes["db.collection.name"], "demo") + finally: + self._uninstrument() + + def test_scan_foreach_creates_span(self): + """Test that scan.foreach() creates the correct span.""" + mock_scan = MagicMock() + mock_scan.foreach.return_value = None + self.mock_client.scan.return_value = mock_scan + self._instrument() + + try: + client = self.mock_aerospike.client({}) + scan = client.scan("test", "demo") + scan.foreach(lambda rec: None) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "SCAN test.demo") + self.assertEqual(span.attributes["db.operation.name"], "SCAN") + finally: + self._uninstrument() + + def test_truncate_admin_method(self): + """Test truncate admin operation creates correct span.""" + self.mock_client.truncate.return_value = None + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.truncate("test", "demo", 0) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "TRUNCATE test.demo") + self.assertEqual(span.attributes["db.operation.name"], "TRUNCATE") + finally: + self._uninstrument() + + def test_info_all_admin_method(self): + """Test info_all admin operation creates correct span without namespace.""" + self.mock_client.info_all.return_value = {} + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.info_all("status") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.attributes["db.operation.name"], "INFO_ALL") + # info_all takes a command string, not namespace — must not leak + self.assertNotIn("db.namespace", span.attributes) + finally: + self._uninstrument() + + def test_connect_returns_self(self): + """Test that connect() returns the instrumented client (self).""" + self._instrument() + + try: + client = self.mock_aerospike.client({}) + result = client.connect() + + self.assertIs(result, client) + finally: + self._uninstrument() + + def test_update_server_info_from_nodes(self): + """Test that server info is updated from connected nodes.""" + mock_node = MagicMock() + mock_node.name = "192.168.1.1:3000" + self.mock_client.get_nodes.return_value = [mock_node] + + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.connect() + + self.assertEqual(client._server_address, "192.168.1.1") + self.assertEqual(client._server_port, 3000) + finally: + self._uninstrument() + + def test_update_server_info_from_tuple_nodes(self): + """Test that server info handles tuple-style node return.""" + self.mock_client.get_nodes.return_value = [("10.0.0.1", 3000)] + + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.connect() + + self.assertEqual(client._server_address, "10.0.0.1") + self.assertEqual(client._server_port, 3000) + finally: + self._uninstrument() + + def test_update_server_info_no_nodes(self): + """Test that server info gracefully handles no nodes.""" + self.mock_client.get_nodes.return_value = [] + + config = {"hosts": [("127.0.0.1", 3000)]} + self._instrument() + + try: + client = self.mock_aerospike.client(config) + client.connect() + + # Should keep the config-based values + self.assertEqual(client._server_address, "127.0.0.1") + self.assertEqual(client._server_port, 3000) + finally: + self._uninstrument() + + def test_batch_empty_keys(self): + """Test batch operation with empty keys list.""" + self.mock_client.batch_read.return_value = [] + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.batch_read([]) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "BATCH READ") + self.assertNotIn("db.namespace", span.attributes) + finally: + self._uninstrument() + + def test_single_record_with_malformed_key(self): + """Test single record operation with non-tuple key.""" + self.mock_client.get.return_value = (None, None, None) + self._instrument() + + try: + client = self.mock_aerospike.client({}) + client.get("not_a_tuple") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.name, "GET") + self.assertNotIn("db.namespace", span.attributes) + finally: + self._uninstrument() + + def test_wrapper_caching(self): + """Test that __getattr__ caches wrapped methods.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + self._instrument() + + try: + client = self.mock_aerospike.client({}) + + # First call triggers __getattr__ and caches the wrapper + wrapper1 = client.get + # Second call should return the cached wrapper + wrapper2 = client.get + + self.assertIs(wrapper1, wrapper2) + finally: + self._uninstrument() + + def test_user_attribute_from_config(self): + """Test that user attribute is captured from config.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + + config = {"hosts": [("127.0.0.1", 3000)], "user": "test_user"} + self._instrument() + + try: + client = self.mock_aerospike.client(config) + client.get(("test", "demo", "key1")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.attributes.get("db.user"), "test_user") + finally: + self._uninstrument() + + def test_request_hook_exception_does_not_break_operation(self): + """Test that request_hook exception does not break the operation.""" + self.mock_client.put.return_value = None + + def bad_request_hook(span, operation, args, kwargs): + raise RuntimeError("request_hook failed") + + self._instrument(request_hook=bad_request_hook) + + try: + client = self.mock_aerospike.client({}) + # Should NOT raise despite hook failure + client.put(("test", "demo", "key1"), {"bin": "value"}) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "PUT test.demo") + # Verify the underlying method was still called + self.mock_client.put.assert_called_once() + finally: + self._uninstrument() + + def test_response_hook_exception_does_not_break_operation(self): + """Test that response_hook exception does not break the operation.""" + self.mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + + def bad_response_hook(span, operation, result): + raise RuntimeError("response_hook failed") + + self._instrument(response_hook=bad_response_hook) + + try: + client = self.mock_aerospike.client({}) + # Should NOT raise despite hook failure + result = client.get(("test", "demo", "key1")) + + # Verify result is returned correctly + self.assertIsNotNone(result) + self.assertEqual(result[2], {"bin1": "value1"}) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "GET test.demo") + finally: + self._uninstrument() + + def test_error_hook_exception_does_not_break_operation(self): + """Test that error_hook exception does not suppress the original error.""" + original_error = ValueError("Record not found") + original_error.code = 2 + self.mock_client.get.side_effect = original_error + + def bad_error_hook(span, operation, exception): + raise RuntimeError("error_hook failed") + + self._instrument(error_hook=bad_error_hook) + + try: + client = self.mock_aerospike.client({}) + # Should raise the ORIGINAL error, not the hook error + with self.assertRaises(ValueError) as ctx: + client.get(("test", "demo", "key1")) + + self.assertEqual(str(ctx.exception), "Record not found") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].status.status_code, StatusCode.ERROR) + finally: + self._uninstrument() + + def test_bins_attribute(self): + """Test that bins are captured for PUT and SELECT.""" + self.mock_client.put.return_value = None + self.mock_client.select.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + self._instrument() + + try: + client = self.mock_aerospike.client({}) + + # Test PUT with bins dict + client.put( + ("test", "demo", "key1"), {"bin1": "val1", "bin2": "val2"} + ) + # Test SELECT with bins list + client.select(("test", "demo", "key1"), ["bin1", "bin2"]) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + + put_span = spans[0] + self.assertEqual(put_span.name, "PUT test.demo") + # Verify bins keys are captured + captured_bins = put_span.attributes.get("db.aerospike.bins") + self.assertIn("bin1", captured_bins) + self.assertIn("bin2", captured_bins) + + select_span = spans[1] + self.assertEqual(select_span.name, "SELECT test.demo") + # Verify bins list is captured + captured_bins = select_span.attributes.get("db.aerospike.bins") + self.assertIn("bin1", captured_bins) + self.assertIn("bin2", captured_bins) + finally: + self._uninstrument() diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml index 7685487c76..aa8324716f 100644 --- a/opentelemetry-contrib-instrumentations/pyproject.toml +++ b/opentelemetry-contrib-instrumentations/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ + "opentelemetry-instrumentation-aerospike==0.61b0.dev", "opentelemetry-instrumentation-aio-pika==0.61b0.dev", "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev", "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev", diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 47ed388c32..8b108cd337 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -24,6 +24,10 @@ "library": "google-cloud-aiplatform >= 1.64", "instrumentation": "opentelemetry-instrumentation-vertexai>=2.0b0", }, + { + "library": "aerospike >= 17.0.0", + "instrumentation": "opentelemetry-instrumentation-aerospike==0.61b0.dev", + }, { "library": "aio_pika >= 7.2.0, < 10.0.0", "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev", diff --git a/test_udf.lua b/test_udf.lua new file mode 100644 index 0000000000..cf427a29e3 --- /dev/null +++ b/test_udf.lua @@ -0,0 +1,7 @@ +function echo(rec, val) + return val +end + +function noop(rec) + return 0 +end diff --git a/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py new file mode 100644 index 0000000000..56959046be --- /dev/null +++ b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py @@ -0,0 +1,326 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import pathlib + +import aerospike +from aerospike import exception as aerospike_exc + +from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import SpanKind, StatusCode + +AEROSPIKE_HOST = os.getenv("AEROSPIKE_HOST", "localhost") +AEROSPIKE_PORT = int(os.getenv("AEROSPIKE_PORT", "3000")) + +_UDF_PATH = str(pathlib.Path(__file__).with_name("test_udf.lua")) + + +class TestFunctionalAerospike(TestBase): + """Functional tests against a real Aerospike server.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Register UDF once for the entire test class + config = {"hosts": [(AEROSPIKE_HOST, AEROSPIKE_PORT)]} + client = aerospike.client(config).connect() + client.udf_put(_UDF_PATH) + client.close() + + def setUp(self): + super().setUp() + self._tracer = self.tracer_provider.get_tracer(__name__) + AerospikeInstrumentor().instrument( + tracer_provider=self.tracer_provider + ) + config = {"hosts": [(AEROSPIKE_HOST, AEROSPIKE_PORT)]} + self._client = aerospike.client(config) + self._client.connect() + + def tearDown(self): + self._client.close() + AerospikeInstrumentor().uninstrument() + super().tearDown() + + # -- helpers -- + + def _get_spans(self): + return self.memory_exporter.get_finished_spans() + + def _assert_base_attrs( + self, span, operation, namespace=None, set_name=None + ): + """Assert common span attributes.""" + self.assertEqual(span.kind, SpanKind.CLIENT) + self.assertEqual(span.attributes["db.system.name"], "aerospike") + self.assertEqual(span.attributes["db.operation.name"], operation) + if namespace: + self.assertEqual(span.attributes["db.namespace"], namespace) + if set_name: + self.assertEqual(span.attributes["db.collection.name"], set_name) + self.assertIn("server.address", span.attributes) + self.assertIn("server.port", span.attributes) + + # -- tests -- + + def test_put_and_get(self): + """Test put and get create correct spans.""" + key = ("test", "demo", "func_key1") + self._client.put(key, {"name": "alice", "age": 30}) + _, meta, bins = self._client.get(key) + + self.assertEqual(bins["name"], "alice") + self.assertEqual(bins["age"], 30) + + spans = self._get_spans() + self.assertEqual(len(spans), 2) + + put_span, get_span = spans[0], spans[1] + + self.assertEqual(put_span.name, "PUT test.demo") + self._assert_base_attrs(put_span, "PUT", "test", "demo") + + self.assertEqual(get_span.name, "GET test.demo") + self._assert_base_attrs(get_span, "GET", "test", "demo") + # get should capture generation/ttl metadata + self.assertIn("db.aerospike.generation", get_span.attributes) + self.assertIn("db.aerospike.ttl", get_span.attributes) + + # cleanup + self._client.remove(key) + + def test_exists(self): + """Test exists operation.""" + key = ("test", "demo", "func_exists") + self._client.put(key, {"x": 1}) + self.memory_exporter.clear() + + _, meta = self._client.exists(key) + self.assertIsNotNone(meta) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + self._assert_base_attrs(spans[0], "EXISTS", "test", "demo") + + self._client.remove(key) + + def test_remove(self): + """Test remove operation.""" + key = ("test", "demo", "func_remove") + self._client.put(key, {"x": 1}) + self.memory_exporter.clear() + + self._client.remove(key) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "REMOVE test.demo") + self._assert_base_attrs(spans[0], "REMOVE", "test", "demo") + + def test_batch_read(self): + """Test batch_read operation.""" + keys = [("test", "demo", f"func_batch_{i}") for i in range(3)] + for k in keys: + self._client.put(k, {"v": 1}) + self.memory_exporter.clear() + + self._client.batch_read(keys) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "BATCH READ test.demo") + self._assert_base_attrs(span, "BATCH READ", "test", "demo") + self.assertEqual(span.attributes["db.operation.batch.size"], 3) + + for k in keys: + self._client.remove(k) + + def test_query_no_span_on_factory(self): + """Test that query factory alone does not create a span.""" + self._client.query("test", "demo") + + spans = self._get_spans() + self.assertEqual(len(spans), 0) + + def test_query(self): + """Test query.results() creates a span with actual data.""" + key = ("test", "demo", "func_query") + self._client.put(key, {"name": "bob"}) + self.memory_exporter.clear() + + query = self._client.query("test", "demo") + records = query.results() + + self.assertTrue(len(records) > 0) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "QUERY test.demo") + self._assert_base_attrs(spans[0], "QUERY", "test", "demo") + + self._client.remove(key) + + def test_query_foreach(self): + """Test query.foreach() creates a span and invokes the callback.""" + key = ("test", "demo", "func_qforeach") + self._client.put(key, {"name": "carol"}) + self.memory_exporter.clear() + + received = [] + + def callback(record): + received.append(record) + + query = self._client.query("test", "demo") + query.foreach(callback) + + self.assertTrue(len(received) > 0) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "QUERY test.demo") + self._assert_base_attrs(spans[0], "QUERY", "test", "demo") + + self._client.remove(key) + + def test_scan(self): + """Test scan.results() creates a span with actual data.""" + key = ("test", "demo", "func_scan") + self._client.put(key, {"name": "dave"}) + self.memory_exporter.clear() + + scan = self._client.scan("test", "demo") + records = scan.results() + + self.assertTrue(len(records) > 0) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "SCAN test.demo") + self._assert_base_attrs(spans[0], "SCAN", "test", "demo") + + self._client.remove(key) + + def test_apply_udf(self): + """Test apply (single-record UDF) creates correct span.""" + key = ("test", "demo", "func_udf") + self._client.put(key, {"x": 1}) + self.memory_exporter.clear() + + result = self._client.apply(key, "test_udf", "echo", [42]) + self.assertEqual(result, 42) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "APPLY test.demo") + self._assert_base_attrs(span, "APPLY", "test", "demo") + self.assertEqual( + span.attributes["db.aerospike.udf.module"], "test_udf" + ) + self.assertEqual(span.attributes["db.aerospike.udf.function"], "echo") + + self._client.remove(key) + + def test_scan_apply_udf(self): + """Test scan_apply creates correct span with UDF attributes.""" + key = ("test", "demo", "func_scan_apply") + self._client.put(key, {"x": 1}) + self.memory_exporter.clear() + + job_id = self._client.scan_apply("test", "demo", "test_udf", "noop") + self.assertIsNotNone(job_id) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "SCAN APPLY test.demo") + self._assert_base_attrs(span, "SCAN APPLY", "test", "demo") + self.assertEqual( + span.attributes["db.aerospike.udf.module"], "test_udf" + ) + self.assertEqual(span.attributes["db.aerospike.udf.function"], "noop") + + self._client.remove(key) + + def test_truncate(self): + """Test truncate admin operation.""" + key = ("test", "demo", "func_trunc") + self._client.put(key, {"x": 1}) + self.memory_exporter.clear() + + self._client.truncate("test", "demo", 0) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "TRUNCATE test.demo") + self._assert_base_attrs(spans[0], "TRUNCATE", "test", "demo") + + def test_error_case_record_not_found(self): + """Test that a missing record raises an error with proper span status.""" + key = ("test", "demo", "nonexistent_key_xyz") + + with self.assertRaises(aerospike_exc.RecordNotFound): + self._client.get(key) + + spans = self._get_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertIn("error.type", span.attributes) + + def test_parent_span_propagation(self): + """Test that parent span context is propagated to child spans.""" + key = ("test", "demo", "func_parent") + + with self._tracer.start_as_current_span("parent-op"): + self._client.put(key, {"v": 1}) + self._client.get(key) + + spans = self._get_spans() + # Should have 3 spans: parent, put, get + self.assertEqual(len(spans), 3) + + parent_span = [s for s in spans if s.name == "parent-op"][0] + child_spans = [s for s in spans if s.name != "parent-op"] + + for child in child_spans: + self.assertEqual( + child.context.trace_id, + parent_span.context.trace_id, + ) + self.assertEqual( + child.parent.span_id, + parent_span.context.span_id, + ) + + self._client.remove(key) + + def test_connect_span_not_created(self): + """Test that connect itself does not create a span (it's not instrumented as an operation).""" + # connect() is called in setUp, clear spans + self.memory_exporter.clear() + + # Create a new client and connect + config = {"hosts": [(AEROSPIKE_HOST, AEROSPIKE_PORT)]} + client2 = aerospike.client(config) + client2.connect() + client2.close() + + spans = self._get_spans() + # connect/close are not in the instrumented method lists + self.assertEqual(len(spans), 0) diff --git a/tests/opentelemetry-docker-tests/tests/aerospike/test_udf.lua b/tests/opentelemetry-docker-tests/tests/aerospike/test_udf.lua new file mode 100644 index 0000000000..cf427a29e3 --- /dev/null +++ b/tests/opentelemetry-docker-tests/tests/aerospike/test_udf.lua @@ -0,0 +1,7 @@ +function echo(rec, val) + return val +end + +function noop(rec) + return 0 +end diff --git a/tests/opentelemetry-docker-tests/tests/check_availability.py b/tests/opentelemetry-docker-tests/tests/check_availability.py index 70268a0c0b..3e137e1f71 100644 --- a/tests/opentelemetry-docker-tests/tests/check_availability.py +++ b/tests/opentelemetry-docker-tests/tests/check_availability.py @@ -15,6 +15,7 @@ import os import time +import aerospike import mysql.connector import psycopg2 import pymongo @@ -37,6 +38,8 @@ POSTGRES_USER = os.getenv("POSTGRESQL_USER", "testuser") REDIS_HOST = os.getenv("REDIS_HOST", "localhost") REDIS_PORT = int(os.getenv("REDIS_PORT ", "6379")) +AEROSPIKE_HOST = os.getenv("AEROSPIKE_HOST", "localhost") +AEROSPIKE_PORT = int(os.getenv("AEROSPIKE_PORT", "3000")) MSSQL_DB_NAME = os.getenv("MSSQL_DB_NAME", "opentelemetry-tests") MSSQL_HOST = os.getenv("MSSQL_HOST", "localhost") MSSQL_PORT = int(os.getenv("MSSQL_PORT", "1433")) @@ -135,6 +138,14 @@ def setup_mssql_db(): conn.close() +@retryable +def check_aerospike_connection(): + client = aerospike.client( + {"hosts": [(AEROSPIKE_HOST, AEROSPIKE_PORT)]} + ).connect() + client.close() + + def check_docker_services_availability(): # Check if Docker services accept connections check_pymongo_connection() @@ -143,6 +154,7 @@ def check_docker_services_availability(): check_redis_connection() check_mssql_connection() setup_mssql_db() + check_aerospike_connection() check_docker_services_availability() diff --git a/tests/opentelemetry-docker-tests/tests/docker-compose.yml b/tests/opentelemetry-docker-tests/tests/docker-compose.yml index 02a3721d9b..759ef1d33d 100644 --- a/tests/opentelemetry-docker-tests/tests/docker-compose.yml +++ b/tests/opentelemetry-docker-tests/tests/docker-compose.yml @@ -58,3 +58,7 @@ services: ACCEPT_EULA: "Y" SA_PASSWORD: "yourStrong(!)Password" command: /opt/mssql/bin/sqlservr + otaerospike: + image: aerospike/aerospike-server:latest + ports: + - "3000:3000" diff --git a/tests/opentelemetry-docker-tests/tests/test-requirements.txt b/tests/opentelemetry-docker-tests/tests/test-requirements.txt index 048d35cb4c..570afc4f9b 100644 --- a/tests/opentelemetry-docker-tests/tests/test-requirements.txt +++ b/tests/opentelemetry-docker-tests/tests/test-requirements.txt @@ -74,4 +74,5 @@ vine==5.1.0 wcwidth==0.2.13 websocket-client==0.59.0 wrapt==1.16.0 +aerospike>=17.0.0 zipp==3.18.0 diff --git a/tox.ini b/tox.ini index 478dc0d8bc..aa305b453b 100644 --- a/tox.ini +++ b/tox.ini @@ -76,6 +76,11 @@ envlist = ; instrumentation-aiopg intentionally excluded from pypy3 lint-instrumentation-aiopg + ; opentelemetry-instrumentation-aerospike + py3{9,10,11,12,13}-test-instrumentation-aerospike + ; instrumentation-aerospike intentionally excluded from pypy3 (C extension) + lint-instrumentation-aerospike + ; opentelemetry-instrumentation-aws-lambda py3{9,10,11,12,13}-test-instrumentation-aws-lambda pypy3-test-instrumentation-aws-lambda @@ -680,6 +685,9 @@ deps = aiopg: {[testenv]test_deps} aiopg: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-aiopg/test-requirements.txt + aerospike: {[testenv]test_deps} + aerospike: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-aerospike/test-requirements.txt + richconsole: {[testenv]test_deps} richconsole: -r {toxinidir}/exporter/opentelemetry-exporter-richconsole/test-requirements.txt @@ -785,6 +793,9 @@ commands = test-instrumentation-aiopg: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-aiopg/tests {posargs} lint-instrumentation-aiopg: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-aiopg" + test-instrumentation-aerospike: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-aerospike/tests {posargs} + lint-instrumentation-aerospike: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-aerospike" + test-instrumentation-asgi: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-asgi/tests {posargs} lint-instrumentation-asgi: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-asgi" @@ -1035,6 +1046,7 @@ deps = -e {toxinidir}/instrumentation/opentelemetry-instrumentation-pymssql -e {toxinidir}/instrumentation/opentelemetry-instrumentation-sqlalchemy -e {toxinidir}/instrumentation/opentelemetry-instrumentation-aiopg + -e {toxinidir}/instrumentation/opentelemetry-instrumentation-aerospike -e {toxinidir}/instrumentation/opentelemetry-instrumentation-redis -e {toxinidir}/instrumentation/opentelemetry-instrumentation-remoulade -e {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi diff --git a/uv.lock b/uv.lock index b1d8da5774..8377035398 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ members = [ "opentelemetry-exporter-prometheus-remote-write", "opentelemetry-exporter-richconsole", "opentelemetry-instrumentation", + "opentelemetry-instrumentation-aerospike", "opentelemetry-instrumentation-aio-pika", "opentelemetry-instrumentation-aiohttp-client", "opentelemetry-instrumentation-aiohttp-server", @@ -80,6 +81,44 @@ members = [ "opentelemetry-util-http", ] +[[package]] +name = "aerospike" +version = "18.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/5c/7f787d2d32fcb54a280560ba039d9cf81d99658f8929d753938b8dc1b088/aerospike-18.1.0.tar.gz", hash = "sha256:a2b58a0d9f4bd7ace6573618df7b4ccfede6915c1e0ee6d705ea07be9528fb79", size = 2257546, upload-time = "2025-12-17T01:16:43.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/bb/7382c0e784b42edd1d888dc4bf72e53668eb136a2b35ec2319d442f27d9f/aerospike-18.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:b68b39703015634a3ade14c27748dda56abd17075b394a5a8f10863974e9b11e", size = 3347942, upload-time = "2025-12-17T01:15:58.565Z" }, + { url = "https://files.pythonhosted.org/packages/c6/53/6b8ef4780e4e0cfdd9760b3e0b47dbb485865a9003a6f8771e39e0f06a89/aerospike-18.1.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:96f5b93d17eeb3407bcaea8253ca4af39d42412be83d5e64b12fa8b0712ad075", size = 3150036, upload-time = "2025-12-17T01:16:00.293Z" }, + { url = "https://files.pythonhosted.org/packages/2e/75/971fb10745b787b61e10862081e5cbdf84014a75564e76d6cab3462c60bb/aerospike-18.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6a6e2f5f0fd95a067b5f7d7f5781dfaa3886e89182db3b19795e16505ea02cdd", size = 5976980, upload-time = "2025-12-17T01:16:01.775Z" }, + { url = "https://files.pythonhosted.org/packages/26/77/0b667bb9138e916412c73e50eb5a71de4f8cf6c2180fadb9a0dfeabf7072/aerospike-18.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:991307cc8369cc382f3660c03ac988ea4deaf78328354da834b5c69bdc79296c", size = 6101490, upload-time = "2025-12-17T01:16:03.46Z" }, + { url = "https://files.pythonhosted.org/packages/b7/40/2d0616adf893bc89213c0c08a03ac10acf4965ed0ecc7cb773fee4424de0/aerospike-18.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:5fe2098d00dc80bf2167fc7fcb7ca7cc55a96a7f68846f83958b9d5653f7218e", size = 3377211, upload-time = "2025-12-17T01:16:05.27Z" }, + { url = "https://files.pythonhosted.org/packages/06/f5/0897cdf906c7ce306d0061f4a7011fb367a540ccc379794b84e6f2023f49/aerospike-18.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:077755929f5e81b336204a5169508498cbbe069a49a1ca2f370fbb6c747c2cc1", size = 3347880, upload-time = "2025-12-17T01:16:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/36/85/a826fe32e9f1da3fc230e7cc2a6820d5fb076d5846588d9996bf0e91924c/aerospike-18.1.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:785c6cc917d6a5a34f08ee52c05f1fbf9d5bbdb6f2f895a631b23534ba74c031", size = 3150033, upload-time = "2025-12-17T01:16:08.168Z" }, + { url = "https://files.pythonhosted.org/packages/22/54/5354c0d63de1c8fbeb086803b7906e509144781e428164aa6fe7022d5282/aerospike-18.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a867a7bf7d5fe82eb7943994f4660060f895e4019e63947e9b0d5e35b91a7cd3", size = 6003560, upload-time = "2025-12-17T01:16:10.115Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e7/f1c1de42730b8470deaf0111f2f020637310f98dd11a31e16c66a258ed92/aerospike-18.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:77fdbbdf4e500a4983111edaa15d8848699882320b8a7e186cc8de713fb47119", size = 6128402, upload-time = "2025-12-17T01:16:12.061Z" }, + { url = "https://files.pythonhosted.org/packages/ba/18/1420820be63b2ad36950ff5b4cf63f28ab9e3323dd89b7a61fced0c6c161/aerospike-18.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:1e8794c7ca70bf95e919de7441c311df7b8c5a502f097b16e591a2de5d1dcd4e", size = 3377356, upload-time = "2025-12-17T01:16:13.643Z" }, + { url = "https://files.pythonhosted.org/packages/c2/50/10f2e08dacd8be728bdff464d93e2de4d58fc524db4d501a106afc8b5e99/aerospike-18.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cef882da747dee0409ab8c5ecbd7558658e9a6d9d494bca48a0f2c5fa6c3e137", size = 3348707, upload-time = "2025-12-17T01:16:14.942Z" }, + { url = "https://files.pythonhosted.org/packages/33/ac/37bdfc8838db366b5eec247c0ab0aa622c79978bbcee3061c9645729745e/aerospike-18.1.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:995652f500056af9d04151699b968805b07e3021002e3e64d6318d700cb4600a", size = 3151222, upload-time = "2025-12-17T01:16:16.43Z" }, + { url = "https://files.pythonhosted.org/packages/65/42/fac98e9edaa64db8c1c7127f304bcd530070aa66a804e96671538efc022c/aerospike-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ea16a19705a93ab8e97671973520a0ae83eec3f574c44eddbfbaaa86e7e355f9", size = 6035269, upload-time = "2025-12-17T01:16:17.982Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ca/7d7400452bff4385a02409056216f80363d99991c5c1cdece980d350069f/aerospike-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:13123610d4587378660cf23cdba5560a802859db7a0ddef162c31b44106914df", size = 6160653, upload-time = "2025-12-17T01:16:19.431Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/8afbb7c9c52fc3cf93ec948b60e2efadf91184d5112da4c45fa3764a58f3/aerospike-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0c643b2688af91f28066adc17b4c1e5d000246225713557b75cff283ea3c8ea", size = 3378082, upload-time = "2025-12-17T01:16:20.903Z" }, + { url = "https://files.pythonhosted.org/packages/91/2a/3547c9649f58c87d748f1a651a3179a8706b53af9abc41899bb3003563cd/aerospike-18.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ad4d5b98def7916969b76eaf8c10deebcb249fdc23c085b226026837662757fe", size = 3348341, upload-time = "2025-12-17T01:16:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/64ba4cebe5377de35d7a3f82c013e0a196520cfa473a5efb150edbe240a2/aerospike-18.1.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:f8d1a0aab33d97d64893056a98198bf30bddf9a10193a93bf029cd1fa54f2fbf", size = 3150967, upload-time = "2025-12-17T01:16:23.726Z" }, + { url = "https://files.pythonhosted.org/packages/20/ee/96558838a2f35e93713819558dd376bf50da72f29be33217ac9382b0b0e1/aerospike-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:87992ea0ddf495de66d48804227cf92090fecccb66458c8ccc96ec10a898ae0e", size = 6040309, upload-time = "2025-12-17T01:16:25.076Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/eb96f1380bce523634da3df73dae5eba0b9dab8b0b2860fa279decbe0383/aerospike-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4c5b9ef124cc997525025fc74eccfb94c51c994a6c34bbd6a42aee4961ff1652", size = 6167572, upload-time = "2025-12-17T01:16:26.506Z" }, + { url = "https://files.pythonhosted.org/packages/39/ac/6c91cd8e9aa1905760b6c7498bf331f889ac6d85b68a77258315164c02f6/aerospike-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8c668d8834425094576a580a5605bfb48f430da26a661ea7d0a23b015b238214", size = 3377953, upload-time = "2025-12-17T01:16:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a0/11aa0df4fe78421afd3ada545ca917eed83116bd014b9c4e3768e6a286bb/aerospike-18.1.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fde595009c8d5011627b9f61da156f71965ba71379ebe5d777491bfce18e87c1", size = 3348579, upload-time = "2025-12-17T01:16:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6e/bd5bc3a5f18883627f1d3274fdfc6e8748c0da47fdd1803f74857e534e38/aerospike-18.1.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:def886b9319ffe82c62e06b5f5b517c41196c78d720eec4760dd31896765dd16", size = 3150741, upload-time = "2025-12-17T01:16:30.494Z" }, + { url = "https://files.pythonhosted.org/packages/06/0a/5761b124e5923848debf1afe3d3d460014faf874a5ba4044b79a9e5f20f8/aerospike-18.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e089ef4fea0251a960951ae63f081252869469d7663c8656c07077a101f283fe", size = 6031240, upload-time = "2025-12-17T01:16:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9d/b39b6f8719b8f594964a4f684e8f04bd549751b720e98eea6d2dace4821e/aerospike-18.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d815bebedfd3822785876326e8598913625852577079f6db33664700a013aacb", size = 6153653, upload-time = "2025-12-17T01:16:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/85/44/1cf831856ce81c9556f868bc92c2ab0d99fef460475c8ccb6c80d1bf1df1/aerospike-18.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d6c54fc6d5a2893ce2200a485619100d37f526a7e4bd84d95315af3d42237632", size = 3488993, upload-time = "2025-12-17T01:16:34.905Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4d/b508fcc10d41d28285b9f25592d28b04768d75444ee5993a156704f39ccf/aerospike-18.1.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:e5795a23d771a3386ddc345876ff29a30069ada8099c179d30340aac31e3d8cd", size = 3347801, upload-time = "2025-12-17T01:16:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/7cc98cd4193a50fd0860b3cec43c75b66a8059676ee0b0823537b3377785/aerospike-18.1.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:8ffc8cf70e9c76555917728e0e816d8bfdc22f80390c5894fa3da03e7e1738f1", size = 3149988, upload-time = "2025-12-17T01:16:37.723Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/55cf356fc508321d4b90a9b218085f295cc287117d728b4f076da8feab2d/aerospike-18.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:1adffe19f896dc7e07608639bfa0e1aafdb46ede4d24b175c400413b675385ce", size = 5976004, upload-time = "2025-12-17T01:16:39.24Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3c/4def8a0d99cd7d5c0ad50e9e09466837dd8f17d1b2c8a24ca128d5b2947e/aerospike-18.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6c24ba7713f8c7d87689822a863ad616c96befc18b50ae7796bf70a7cb478436", size = 6098955, upload-time = "2025-12-17T01:16:40.618Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a2/e86748156cfaac3faef1902ef0f950222128c18024a899072292c9c57b00/aerospike-18.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:eaabe80cbfad0f488402fb497cb3515fc655a6029c60be628e94e3fb3a4cb2a1", size = 3378918, upload-time = "2025-12-17T01:16:41.906Z" }, +] + [[package]] name = "aio-pika" version = "9.5.5" @@ -1595,7 +1634,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/66/910217271189cc3f32f670040235f4bf026ded8ca07270667d69c06e7324/greenlet-3.2.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6", size = 267395, upload-time = "2025-05-09T14:50:45.357Z" }, { url = "https://files.pythonhosted.org/packages/a8/36/8d812402ca21017c82880f399309afadb78a0aa300a9b45d741e4df5d954/greenlet-3.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7", size = 625742, upload-time = "2025-05-09T15:23:58.293Z" }, { url = "https://files.pythonhosted.org/packages/7b/77/66d7b59dfb7cc1102b2f880bc61cb165ee8998c9ec13c96606ba37e54c77/greenlet-3.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c", size = 637014, upload-time = "2025-05-09T15:24:47.025Z" }, - { url = "https://files.pythonhosted.org/packages/36/a7/ff0d408f8086a0d9a5aac47fa1b33a040a9fca89bd5a3f7b54d1cd6e2793/greenlet-3.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907", size = 632874, upload-time = "2025-05-09T15:29:20.014Z" }, { url = "https://files.pythonhosted.org/packages/a1/75/1dc2603bf8184da9ebe69200849c53c3c1dca5b3a3d44d9f5ca06a930550/greenlet-3.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f", size = 631652, upload-time = "2025-05-09T14:53:30.961Z" }, { url = "https://files.pythonhosted.org/packages/7b/74/ddc8c3bd4c2c20548e5bf2b1d2e312a717d44e2eca3eadcfc207b5f5ad80/greenlet-3.2.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13", size = 580619, upload-time = "2025-05-09T14:53:42.049Z" }, { url = "https://files.pythonhosted.org/packages/7e/f2/40f26d7b3077b1c7ae7318a4de1f8ffc1d8ccbad8f1d8979bf5080250fd6/greenlet-3.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5", size = 1109809, upload-time = "2025-05-09T15:26:59.063Z" }, @@ -1604,7 +1642,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/9f/a47e19261747b562ce88219e5ed8c859d42c6e01e73da6fbfa3f08a7be13/greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068", size = 268635, upload-time = "2025-05-09T14:50:39.007Z" }, { url = "https://files.pythonhosted.org/packages/11/80/a0042b91b66975f82a914d515e81c1944a3023f2ce1ed7a9b22e10b46919/greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce", size = 628786, upload-time = "2025-05-09T15:24:00.692Z" }, { url = "https://files.pythonhosted.org/packages/38/a2/8336bf1e691013f72a6ebab55da04db81a11f68e82bb691f434909fa1327/greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b", size = 640866, upload-time = "2025-05-09T15:24:48.153Z" }, - { url = "https://files.pythonhosted.org/packages/f8/7e/f2a3a13e424670a5d08826dab7468fa5e403e0fbe0b5f951ff1bc4425b45/greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3", size = 636752, upload-time = "2025-05-09T15:29:23.182Z" }, { url = "https://files.pythonhosted.org/packages/fd/5d/ce4a03a36d956dcc29b761283f084eb4a3863401c7cb505f113f73af8774/greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74", size = 636028, upload-time = "2025-05-09T14:53:32.854Z" }, { url = "https://files.pythonhosted.org/packages/4b/29/b130946b57e3ceb039238413790dd3793c5e7b8e14a54968de1fe449a7cf/greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe", size = 583869, upload-time = "2025-05-09T14:53:43.614Z" }, { url = "https://files.pythonhosted.org/packages/ac/30/9f538dfe7f87b90ecc75e589d20cbd71635531a617a336c386d775725a8b/greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e", size = 1112886, upload-time = "2025-05-09T15:27:01.304Z" }, @@ -1613,7 +1650,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/a1/88fdc6ce0df6ad361a30ed78d24c86ea32acb2b563f33e39e927b1da9ea0/greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330", size = 270413, upload-time = "2025-05-09T14:51:32.455Z" }, { url = "https://files.pythonhosted.org/packages/a6/2e/6c1caffd65490c68cd9bcec8cb7feb8ac7b27d38ba1fea121fdc1f2331dc/greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b", size = 637242, upload-time = "2025-05-09T15:24:02.63Z" }, { url = "https://files.pythonhosted.org/packages/98/28/088af2cedf8823b6b7ab029a5626302af4ca1037cf8b998bed3a8d3cb9e2/greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e", size = 651444, upload-time = "2025-05-09T15:24:49.856Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0116ab876bb0bc7a81eadc21c3f02cd6100dcd25a1cf2a085a130a63a26a/greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275", size = 646067, upload-time = "2025-05-09T15:29:24.989Z" }, { url = "https://files.pythonhosted.org/packages/35/17/bb8f9c9580e28a94a9575da847c257953d5eb6e39ca888239183320c1c28/greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65", size = 648153, upload-time = "2025-05-09T14:53:34.716Z" }, { url = "https://files.pythonhosted.org/packages/2c/ee/7f31b6f7021b8df6f7203b53b9cc741b939a2591dcc6d899d8042fcf66f2/greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3", size = 603865, upload-time = "2025-05-09T14:53:45.738Z" }, { url = "https://files.pythonhosted.org/packages/b5/2d/759fa59323b521c6f223276a4fc3d3719475dc9ae4c44c2fe7fc750f8de0/greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e", size = 1119575, upload-time = "2025-05-09T15:27:04.248Z" }, @@ -1622,7 +1658,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/30/97b49779fff8601af20972a62cc4af0c497c1504dfbb3e93be218e093f21/greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59", size = 269150, upload-time = "2025-05-09T14:50:30.784Z" }, { url = "https://files.pythonhosted.org/packages/21/30/877245def4220f684bc2e01df1c2e782c164e84b32e07373992f14a2d107/greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf", size = 637381, upload-time = "2025-05-09T15:24:12.893Z" }, { url = "https://files.pythonhosted.org/packages/8e/16/adf937908e1f913856b5371c1d8bdaef5f58f251d714085abeea73ecc471/greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325", size = 651427, upload-time = "2025-05-09T15:24:51.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/49/6d79f58fa695b618654adac64e56aff2eeb13344dc28259af8f505662bb1/greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5", size = 645795, upload-time = "2025-05-09T15:29:26.673Z" }, { url = "https://files.pythonhosted.org/packages/5a/e6/28ed5cb929c6b2f001e96b1d0698c622976cd8f1e41fe7ebc047fa7c6dd4/greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825", size = 648398, upload-time = "2025-05-09T14:53:36.61Z" }, { url = "https://files.pythonhosted.org/packages/9d/70/b200194e25ae86bc57077f695b6cc47ee3118becf54130c5514456cf8dac/greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d", size = 606795, upload-time = "2025-05-09T14:53:47.039Z" }, { url = "https://files.pythonhosted.org/packages/f8/c8/ba1def67513a941154ed8f9477ae6e5a03f645be6b507d3930f72ed508d3/greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf", size = 1117976, upload-time = "2025-05-09T15:27:06.542Z" }, @@ -1630,7 +1665,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023, upload-time = "2025-05-09T14:53:24.157Z" }, { url = "https://files.pythonhosted.org/packages/65/66/8a73aace5a5335a1cba56d0da71b7bd93e450f17d372c5b7c5fa547557e9/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418", size = 629911, upload-time = "2025-05-09T15:24:22.376Z" }, { url = "https://files.pythonhosted.org/packages/48/08/c8b8ebac4e0c95dcc68ec99198842e7db53eda4ab3fb0a4e785690883991/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4", size = 635251, upload-time = "2025-05-09T15:24:52.205Z" }, - { url = "https://files.pythonhosted.org/packages/37/26/7db30868f73e86b9125264d2959acabea132b444b88185ba5c462cb8e571/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763", size = 632620, upload-time = "2025-05-09T15:29:28.051Z" }, { url = "https://files.pythonhosted.org/packages/10/ec/718a3bd56249e729016b0b69bee4adea0dfccf6ca43d147ef3b21edbca16/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b", size = 628851, upload-time = "2025-05-09T14:53:38.472Z" }, { url = "https://files.pythonhosted.org/packages/9b/9d/d1c79286a76bc62ccdc1387291464af16a4204ea717f24e77b0acd623b99/greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207", size = 593718, upload-time = "2025-05-09T14:53:48.313Z" }, { url = "https://files.pythonhosted.org/packages/cd/41/96ba2bf948f67b245784cd294b84e3d17933597dffd3acdb367a210d1949/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8", size = 1105752, upload-time = "2025-05-09T15:27:08.217Z" }, @@ -1639,7 +1673,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/3a/dbf22e1c7c1affc68ad4bc8f06619945c74a92b112ae6a401bed1f1ed63b/greenlet-3.2.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1e4747712c4365ef6765708f948acc9c10350719ca0545e362c24ab973017370", size = 266190, upload-time = "2025-05-09T14:50:53.356Z" }, { url = "https://files.pythonhosted.org/packages/33/b1/21fabb65b13f504e8428595c54be73b78e7a542a2bd08ed9e1c56c8fcee2/greenlet-3.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782743700ab75716650b5238a4759f840bb2dcf7bff56917e9ffdf9f1f23ec59", size = 623904, upload-time = "2025-05-09T15:24:24.588Z" }, { url = "https://files.pythonhosted.org/packages/ec/9e/3346e463f13b593aafc683df6a85e9495a9b0c16c54c41f7e34353adea40/greenlet-3.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:354f67445f5bed6604e493a06a9a49ad65675d3d03477d38a4db4a427e9aad0e", size = 635672, upload-time = "2025-05-09T15:24:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/8e/88/6e8459e4789a276d1a18d656fd95334d21fe0609c6d6f446f88dbfd9483d/greenlet-3.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3aeca9848d08ce5eb653cf16e15bb25beeab36e53eb71cc32569f5f3afb2a3aa", size = 630975, upload-time = "2025-05-09T15:29:29.393Z" }, { url = "https://files.pythonhosted.org/packages/ab/80/81ccf96daf166e8334c37663498dad742d61114cdf801f4872a38e8e31d5/greenlet-3.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb8553ee954536500d88a1a2f58fcb867e45125e600e80f586ade399b3f8819", size = 630252, upload-time = "2025-05-09T14:53:42.765Z" }, { url = "https://files.pythonhosted.org/packages/c1/61/3489e3fd3b7dc81c73368177313231a1a1b30df660a0c117830aa18e0f29/greenlet-3.2.2-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1592a615b598643dbfd566bac8467f06c8c8ab6e56f069e573832ed1d5d528cc", size = 579122, upload-time = "2025-05-09T14:53:49.702Z" }, { url = "https://files.pythonhosted.org/packages/be/55/57685fe335e88f8c75d204f9967e46e5fba601f861fb80821e5fb7ab959d/greenlet-3.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f72667cc341c95184f1c68f957cb2d4fc31eef81646e8e59358a10ce6689457", size = 1108299, upload-time = "2025-05-09T15:27:10.193Z" }, @@ -2657,6 +2690,31 @@ requires-dist = [ { name = "wrapt", specifier = ">=1.0.0,<2.0.0" }, ] +[[package]] +name = "opentelemetry-instrumentation-aerospike" +source = { editable = "instrumentation/opentelemetry-instrumentation-aerospike" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "aerospike" }, +] + +[package.metadata] +requires-dist = [ + { name = "aerospike", marker = "extra == 'instruments'", specifier = ">=17.0.0" }, + { 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 = "wrapt", specifier = ">=1.12.1" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-aio-pika" source = { editable = "instrumentation/opentelemetry-instrumentation-aio-pika" }