From ed04b0bfbb2d279228e607b1a972a45a92a89a1d Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Thu, 29 Jan 2026 01:05:08 +0900 Subject: [PATCH 01/16] feat(instrumentation): add Aerospike instrumentation Add OpenTelemetry instrumentation for Aerospike Python client (>= 17.0.0). Supported operations: - Single record: put, get, select, exists, remove, touch, operate, append, prepend, increment - Batch: batch_read, batch_write, batch_operate, batch_remove, batch_apply - Query/Scan: query, scan - UDF: apply, scan_apply, query_apply - Admin: truncate, info_all Features: - Request/response/error hooks for custom span attributes - Optional key capture (disabled by default for security) - Semantic conventions compliant span attributes --- .github/component_owners.yml | 3 + .github/workflows/core_contrib_test_0.yml | 30 + .github/workflows/lint_0.yml | 19 + .github/workflows/test_0.yml | 190 ++-- .github/workflows/test_1.yml | 190 ++-- .github/workflows/test_2.yml | 95 ++ docs/instrumentation/aerospike/aerospike.rst | 10 + .../LICENSE | 201 +++++ .../README.rst | 72 ++ .../pyproject.toml | 56 ++ .../instrumentation/aerospike/__init__.py | 822 ++++++++++++++++++ .../instrumentation/aerospike/package.py | 15 + .../instrumentation/aerospike/version.py | 15 + .../test-requirements.txt | 4 + .../tests/__init__.py | 13 + .../tests/test_aerospike_instrumentation.py | 791 +++++++++++++++++ tox.ini | 12 + uv.lock | 70 +- 18 files changed, 2412 insertions(+), 196 deletions(-) create mode 100644 docs/instrumentation/aerospike/aerospike.rst create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/LICENSE create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/README.rst create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/pyproject.toml create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/package.py create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/version.py create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/test-requirements.txt create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/tests/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py 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/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/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..a275b7d95e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/README.rst @@ -0,0 +1,72 @@ +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. + +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..4121493ed9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -0,0 +1,822 @@ +# 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 +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 + +# Semantic convention constants +_DB_SYSTEM = "aerospike" +_DB_SYSTEM_ATTR = "db.system" +_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" +_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" + + +class AerospikeInstrumentor(BaseInstrumentor): + """OpenTelemetry Aerospike Instrumentor. + + This instrumentor wraps Aerospike client methods to automatically + create spans for database operations. + + Note: Aerospike Python client is a C extension, so we wrap the client + factory function (aerospike.client) to instrument each client instance. + """ + + # Methods to instrument + _SINGLE_RECORD_METHODS = [ + "put", + "get", + "select", + "exists", + "remove", + "touch", + "operate", + "append", + "prepend", + "increment", + ] + + _BATCH_METHODS = [ + "batch_read", + "batch_write", + "batch_operate", + "batch_remove", + "batch_apply", + # Note: get_many, exists_many, select_many were removed in aerospike 17.0.0 + # Use batch_read() instead + ] + + _QUERY_SCAN_METHODS = ["query", "scan"] + + _UDF_METHODS = ["apply", "scan_apply", "query_apply"] + + _ADMIN_METHODS = ["truncate", "info_all"] + + _original_client = None + + def instrumentation_dependencies(self) -> Collection[str]: + """Return the dependencies required for this instrumentation.""" + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + """Instrument Aerospike client factory function.""" + import aerospike # pylint: disable=import-outside-toplevel + + tracer_provider = kwargs.get("tracer_provider") + tracer = trace.get_tracer( + __name__, + __version__, + tracer_provider, + schema_url="https://opentelemetry.io/schemas/1.28.0", + ) + + request_hook = kwargs.get("request_hook") + response_hook = kwargs.get("response_hook") + error_hook = kwargs.get("error_hook") + capture_key = kwargs.get("capture_key", False) + + # Store original client function + self._original_client = aerospike.client # pylint: disable=c-extension-no-member + + # Wrap the client factory function + wrap_function_wrapper( + "aerospike", + "client", + _create_client_wrapper(tracer, request_hook, response_hook, error_hook, capture_key), + ) + + def _uninstrument(self, **kwargs: Any) -> None: + """Remove instrumentation from Aerospike client factory.""" + import aerospike # 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: + """Create a wrapper for aerospike.client() factory function.""" + + def client_wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: + # Create the original client + client = wrapped(*args, **kwargs) + + # Extract config from args or kwargs + config = None + if args: + config = args[0] + elif "config" in kwargs: + config = kwargs["config"] + + # Wrap the client instance with our instrumented proxy + return InstrumentedAerospikeClient( + client, tracer, request_hook, response_hook, error_hook, capture_key, config + ) + + return client_wrapper + + +class InstrumentedAerospikeClient: + """Instrumented wrapper for Aerospike Client. + + This class wraps an Aerospike client instance and adds + OpenTelemetry tracing to all database operations. + """ + + _SINGLE_RECORD_METHODS = [ + "put", + "get", + "select", + "exists", + "remove", + "touch", + "operate", + "append", + "prepend", + "increment", + ] + + _BATCH_METHODS = [ + "batch_read", + "batch_write", + "batch_operate", + "batch_remove", + "batch_apply", + # Note: get_many, exists_many, select_many were removed in aerospike 17.0.0 + # Use batch_read() instead + ] + + _QUERY_SCAN_METHODS = ["query", "scan"] + + _UDF_METHODS = ["apply", "scan_apply", "query_apply"] + + _ADMIN_METHODS = ["truncate", "info_all"] + + 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 + + # Store server connection info for span attributes + self._server_address = None + self._server_port = None + + # Extract hosts from config if provided + if config and isinstance(config, dict): + hosts = config.get("hosts", []) + if hosts: + try: + first_host = hosts[0] + if isinstance(first_host, tuple) and len(first_host) >= 2: + self._server_address = str(first_host[0]) + self._server_port = int(first_host[1]) + elif isinstance(first_host, tuple) and len(first_host) == 1: + self._server_address = str(first_host[0]) + self._server_port = 3000 + except (TypeError, AttributeError, IndexError): + pass + + def __getattr__(self, name: str) -> Any: + """Proxy attribute access to the wrapped client.""" + attr = getattr(self._client, name) + + # If it's a method we want to instrument, wrap it + if callable(attr): + if name in self._SINGLE_RECORD_METHODS: + return self._wrap_single_record_method(attr, name.upper()) + if name in self._BATCH_METHODS: + op_name = _get_batch_operation_name(name) + return self._wrap_batch_method(attr, op_name) + if name in self._QUERY_SCAN_METHODS: + return self._wrap_query_scan_method(attr, name.upper()) + if name in self._UDF_METHODS: + op_name = name.upper().replace("_", " ") + return self._wrap_udf_method(attr, op_name) + if name in self._ADMIN_METHODS: + return self._wrap_admin_method(attr, name.upper()) + + return attr + + def connect(self, *args, **kwargs) -> InstrumentedAerospikeClient: + """Connect to the Aerospike cluster and cache server address.""" + self._client.connect(*args, **kwargs) + self._update_server_info_from_nodes() + return self + + def _update_server_info_from_nodes(self) -> None: + """Try to get actual connected server info after connection.""" + try: + if not hasattr(self._client, "get_nodes"): + return + nodes = self._client.get_nodes() + if not nodes: + return + node = nodes[0] + if not hasattr(node, "name"): + return + # node.name is typically "host:port" or "host" + node_name = str(node.name) + if ":" in node_name: + host, port = node_name.rsplit(":", 1) + self._server_address = host + try: + self._server_port = int(port) + except ValueError: + self._server_port = 3000 + else: + self._server_address = node_name + self._server_port = 3000 + except (TypeError, AttributeError, IndexError): + # If we can't get node info, keep the config-based values + pass + + def close(self) -> None: + """Close the connection.""" + self._client.close() + + def is_connected(self) -> bool: + """Check if connected.""" + return self._client.is_connected() + + def _set_connection_attributes(self, span: Span) -> None: + """Set connection-related attributes on span.""" + # Use cached server address if available + 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) + + def _wrap_single_record_method(self, method: Callable, operation: str) -> Callable: + """Wrap a single record operation method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + key_tuple = args[0] if args else None + namespace, set_name = _extract_namespace_set_from_key(key_tuple) + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._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) + self._set_connection_attributes(span) + + # Optional: capture key + if self._capture_key and key_tuple and len(key_tuple) > 2: + user_key = key_tuple[2] # pylint: disable=unsubscriptable-object + if user_key is not None: + span.set_attribute(_DB_AEROSPIKE_KEY_ATTR, str(user_key)) + + # Request hook + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + # Response hook + if self._response_hook: + self._response_hook(span, operation, result) + + # Set generation/TTL from result + if span.is_recording(): + _set_result_attributes(span, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + + def _wrap_batch_method(self, method: Callable, operation: str) -> Callable: + """Wrap a batch operation method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + keys = args[0] if args else None + namespace, set_name = _extract_namespace_set_from_batch(keys) + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._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) + self._set_connection_attributes(span) + + # Batch size + if keys and isinstance(keys, list | tuple): + span.set_attribute(_DB_OPERATION_BATCH_SIZE_ATTR, len(keys)) + + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + if self._response_hook: + self._response_hook(span, operation, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + + def _wrap_query_scan_method(self, method: Callable, operation: str) -> Callable: + """Wrap a query/scan operation method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + namespace = args[0] if args else None + set_name = args[1] if len(args) > 1 else None + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._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) + self._set_connection_attributes(span) + + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + if self._response_hook: + self._response_hook(span, operation, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + + def _wrap_udf_method(self, method: Callable, operation: str) -> Callable: + """Wrap a UDF operation method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + key_tuple = args[0] if args else None + namespace, set_name = _extract_namespace_set_from_key(key_tuple) + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._tracer.start_as_current_span( + span_name, + kind=SpanKind.CLIENT, + ) as span: + if span.is_recording(): + self._set_udf_span_attributes( + span, operation, namespace, set_name, args, key_tuple + ) + + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + if self._response_hook: + self._response_hook(span, operation, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + + def _set_udf_span_attributes( + self, + span: Span, + operation: str, + namespace: str | None, + set_name: str | None, + args: tuple, + key_tuple: tuple | None, + ) -> None: + """Set span attributes for UDF operations.""" + 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) + self._set_connection_attributes(span) + + # UDF info + if len(args) > 1: + span.set_attribute("db.aerospike.udf.module", str(args[1])) + if len(args) > 2: + span.set_attribute("db.aerospike.udf.function", str(args[2])) + + # Optional: capture key + if self._capture_key and key_tuple and len(key_tuple) > 2: + user_key = key_tuple[2] # pylint: disable=unsubscriptable-object + if user_key is not None: + span.set_attribute(_DB_AEROSPIKE_KEY_ATTR, str(user_key)) + + def _wrap_admin_method(self, method: Callable, operation: str) -> Callable: + """Wrap an admin operation method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + 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 + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._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) + self._set_connection_attributes(span) + + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + if self._response_hook: + self._response_hook(span, operation, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + + +# Helper functions + + +def _get_batch_operation_name(method: str) -> str: + """Convert batch method name to operation name.""" + method_upper = method.upper() + if method_upper.startswith("BATCH_"): + return f"BATCH {method_upper[6:]}" + if method_upper.endswith("_MANY"): + return f"BATCH {method_upper[:-5]}" + return f"BATCH {method_upper}" + + +def _extract_namespace_set_from_key(key_tuple: tuple | None) -> tuple[str | None, str | None]: + """Extract namespace and set from a single key tuple. + + Key format: (namespace, set, key[, digest]) + """ + if not key_tuple or not isinstance(key_tuple, tuple): + return None, None + + namespace = key_tuple[0] if len(key_tuple) > 0 else None + set_name = key_tuple[1] if len(key_tuple) > 1 else None + return namespace, set_name + + +def _extract_namespace_set_from_batch(keys: list | tuple | None) -> tuple[str | None, str | None]: + """Extract namespace and set from batch keys (uses first key).""" + if not keys or not isinstance(keys, list | tuple): + 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 _generate_span_name(operation: str, namespace: str | None, set_name: str | None) -> str: + """Generate span name following convention: {operation} {namespace}.{set}.""" + 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: + """Set attributes from operation result.""" + if isinstance(result, tuple) and len(result) >= 2: + # Format: (key, meta, bins) or (key, meta) + meta = result[1] if len(result) > 1 else None + 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: + """Set error attributes on span.""" + span.set_status(Status(StatusCode.ERROR, str(exc))) + span.set_attribute(_ERROR_TYPE_ATTR, type(exc).__name__) + + # Aerospike specific error code + if hasattr(exc, "code"): + span.set_attribute(_DB_RESPONSE_STATUS_CODE_ATTR, str(exc.code)) + + +# Public API +__all__ = [ + "AerospikeInstrumentor", + "InstrumentedAerospikeClient", + "__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..c13c4e0ecb --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -0,0 +1,791 @@ +# 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 + +"""Unit tests for OpenTelemetry Aerospike Instrumentation.""" + +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest + +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): + """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"], "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() + + +# Legacy pytest-style tests for backward compatibility +@pytest.fixture +def tracer_setup(): + """Create a tracer provider with in-memory exporter.""" + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return provider, exporter + + +class TestAerospikeInstrumentorUnit: + """Unit tests for AerospikeInstrumentor using mocks.""" + + def test_instrumentation_dependencies(self): + """Test that dependencies are correctly specified.""" + # Create a mock aerospike module + mock_aerospike = MagicMock() + mock_aerospike.client = MagicMock() + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + deps = instrumentor.instrumentation_dependencies() + + assert len(deps) == 1 + assert "aerospike >= 17.0.0" in deps[0] + + def test_instrument_uninstrument(self, tracer_setup): + """Test instrument and uninstrument cycle.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + original_client = MagicMock() + mock_aerospike.client = original_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + + # Instrument + instrumentor.instrument(tracer_provider=provider) + + # Verify client function was wrapped (wrapped function has __wrapped__) + assert hasattr(mock_aerospike.client, "__wrapped__") + + # Uninstrument + instrumentor.uninstrument() + + # Verify client function was unwrapped + assert not hasattr(mock_aerospike.client, "__wrapped__") + + +class TestInstrumentedAerospikeClient: + """Unit tests for InstrumentedAerospikeClient.""" + + def test_proxy_passthrough(self, tracer_setup): + """Test that non-instrumented methods pass through.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.is_connected.return_value = True + mock_client.some_other_method.return_value = "result" + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + # Get instrumented client + client = mock_aerospike.client({}) + + # Test connect method + client.connect() + mock_client.connect.assert_called_once() + + # Test is_connected + assert client.is_connected() is True + + # Test close + client.close() + mock_client.close.assert_called_once() + finally: + instrumentor.uninstrument() + + def test_single_record_operation_creates_span(self, tracer_setup): + """Test that single record operations create spans.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, + ) + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.name == "GET test.demo" + assert span.attributes["db.system"] == "aerospike" + assert span.attributes["db.namespace"] == "test" + assert span.attributes["db.collection.name"] == "demo" + assert span.attributes["db.operation.name"] == "GET" + finally: + instrumentor.uninstrument() + + def test_batch_operation_includes_size(self, tracer_setup): + """Test that batch operations include batch size attribute.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.batch_read.return_value = [] + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + + keys = [ + ("test", "demo", "key1"), + ("test", "demo", "key2"), + ("test", "demo", "key3"), + ] + client.batch_read(keys) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.attributes["db.operation.batch.size"] == 3 + finally: + instrumentor.uninstrument() + + def test_error_sets_span_status(self, tracer_setup): + """Test that errors set span status correctly.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + + error = ValueError("Record not found") + error.code = 2 # KEY_NOT_FOUND + mock_client.get.side_effect = error + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + + with pytest.raises(ValueError): + client.get(("test", "demo", "key1")) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes["error.type"] == "ValueError" + assert span.attributes["db.response.status_code"] == "2" + finally: + instrumentor.uninstrument() + + def test_request_hook_called(self, tracer_setup): + """Test that request hook is called.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.put.return_value = None + mock_aerospike.client.return_value = mock_client + + hook_calls = [] + + def request_hook(span, operation, args, kwargs): + hook_calls.append({"operation": operation}) + span.set_attribute("custom.attr", "test") + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider, request_hook=request_hook) + + try: + client = mock_aerospike.client({}) + client.put(("test", "demo", "key1"), {"bin": "value"}) + + assert len(hook_calls) == 1 + assert hook_calls[0]["operation"] == "PUT" + + spans = exporter.get_finished_spans() + assert spans[0].attributes.get("custom.attr") == "test" + finally: + instrumentor.uninstrument() + + def test_response_hook_called(self, tracer_setup): + """Test that response hook is called.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.get.return_value = (("test", "demo", "key1"), {"gen": 1}, {"bin": "value"}) + mock_aerospike.client.return_value = mock_client + + hook_calls = [] + + def response_hook(span, operation, result): + hook_calls.append({"operation": operation, "result": result}) + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider, response_hook=response_hook) + + try: + client = mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + assert len(hook_calls) == 1 + assert hook_calls[0]["operation"] == "GET" + finally: + instrumentor.uninstrument() + + def test_error_hook_called(self, tracer_setup): + """Test that error hook is called on exception.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.get.side_effect = RuntimeError("Test error") + mock_aerospike.client.return_value = mock_client + + hook_calls = [] + + def error_hook(span, operation, exception): + hook_calls.append({"operation": operation, "error": str(exception)}) + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider, error_hook=error_hook) + + try: + client = mock_aerospike.client({}) + + with pytest.raises(RuntimeError): + client.get(("test", "demo", "key1")) + + assert len(hook_calls) == 1 + assert hook_calls[0]["operation"] == "GET" + assert "Test error" in hook_calls[0]["error"] + finally: + instrumentor.uninstrument() + + def test_capture_key_enabled(self, tracer_setup): + """Test that key is captured when enabled.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.put.return_value = None + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider, capture_key=True) + + try: + client = mock_aerospike.client({}) + client.put(("test", "demo", "my_key"), {"bin": "value"}) + + spans = exporter.get_finished_spans() + assert spans[0].attributes.get("db.aerospike.key") == "my_key" + finally: + instrumentor.uninstrument() + + def test_capture_key_disabled_by_default(self, tracer_setup): + """Test that key is not captured by default.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.put.return_value = None + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.put(("test", "demo", "secret_key"), {"bin": "value"}) + + spans = exporter.get_finished_spans() + assert "db.aerospike.key" not in spans[0].attributes + finally: + instrumentor.uninstrument() + + def test_query_operation(self, tracer_setup): + """Test query operation creates correct span.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_query = MagicMock() + mock_client.query.return_value = mock_query + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.query("test", "users") + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.name == "QUERY test.users" + assert span.attributes["db.operation.name"] == "QUERY" + finally: + instrumentor.uninstrument() + + def test_udf_operation_captures_module_function(self, tracer_setup): + """Test UDF operation captures module and function names.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.apply.return_value = "result" + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.apply(("test", "demo", "key1"), "mymodule", "myfunction", ["arg1"]) + + spans = exporter.get_finished_spans() + span = spans[0] + + assert span.name == "APPLY test.demo" + assert span.attributes.get("db.aerospike.udf.module") == "mymodule" + assert span.attributes.get("db.aerospike.udf.function") == "myfunction" + finally: + instrumentor.uninstrument() + + def test_result_metadata_captured(self, tracer_setup): + """Test that generation and TTL are captured from results.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 5, "ttl": 3600}, + {"bin": "value"}, + ) + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.get(("test", "demo", "key1")) + + spans = exporter.get_finished_spans() + span = spans[0] + + assert span.attributes.get("db.aerospike.generation") == 5 + assert span.attributes.get("db.aerospike.ttl") == 3600 + finally: + instrumentor.uninstrument() + + +class TestSpanNaming: + """Tests for span naming conventions.""" + + def test_span_name_format(self, tracer_setup): + """Test span name follows {operation} {namespace}.{set} format.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.put.return_value = None + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.put(("production", "orders", "order123"), {"total": 100}) + + spans = exporter.get_finished_spans() + assert spans[0].name == "PUT production.orders" + finally: + instrumentor.uninstrument() + + def test_span_name_without_set(self, tracer_setup): + """Test span name when set is None.""" + provider, exporter = tracer_setup + + mock_aerospike = MagicMock() + mock_client = MagicMock() + mock_client.put.return_value = None + mock_aerospike.client.return_value = mock_client + + with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + + instrumentor = AerospikeInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + try: + client = mock_aerospike.client({}) + client.put(("test", None, "key1"), {"bin": "value"}) + + spans = exporter.get_finished_spans() + assert spans[0].name == "PUT test" + finally: + instrumentor.uninstrument() 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" } From cbc0f4caed8c5ba2a9d2a63f13b2b26e616862bd Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Thu, 29 Jan 2026 21:10:21 +0900 Subject: [PATCH 02/16] fix: address lint warnings and Python 3.9 compatibility - Add ruff noqa comments for import-outside-toplevel - Use tuple syntax for isinstance() for Python 3.9 compatibility - Add pylint disable comment for import-outside-toplevel - Register aerospike in bootstrap_gen.py and README --- instrumentation/README.md | 1 + .../instrumentation/aerospike/__init__.py | 65 ++++++-- .../tests/test_aerospike_instrumentation.py | 147 ++++++++++++++---- .../pyproject.toml | 1 + .../instrumentation/bootstrap_gen.py | 4 + 5 files changed, 167 insertions(+), 51 deletions(-) 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/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 4121493ed9..310c46ffa5 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -182,7 +182,10 @@ def error_hook(span, operation, exception): 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.instrumentation.utils import ( + is_instrumentation_enabled, + unwrap, +) from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer # Semantic convention constants @@ -251,7 +254,7 @@ def instrumentation_dependencies(self) -> Collection[str]: def _instrument(self, **kwargs: Any) -> None: """Instrument Aerospike client factory function.""" - import aerospike # pylint: disable=import-outside-toplevel + import aerospike # noqa: PLC0415 # pylint: disable=import-outside-toplevel tracer_provider = kwargs.get("tracer_provider") tracer = trace.get_tracer( @@ -273,12 +276,14 @@ def _instrument(self, **kwargs: Any) -> None: wrap_function_wrapper( "aerospike", "client", - _create_client_wrapper(tracer, request_hook, response_hook, error_hook, capture_key), + _create_client_wrapper( + tracer, request_hook, response_hook, error_hook, capture_key + ), ) def _uninstrument(self, **kwargs: Any) -> None: """Remove instrumentation from Aerospike client factory.""" - import aerospike # pylint: disable=import-outside-toplevel + import aerospike # noqa: PLC0415 # pylint: disable=import-outside-toplevel unwrap(aerospike, "client") @@ -292,7 +297,9 @@ def _create_client_wrapper( ) -> Callable: """Create a wrapper for aerospike.client() factory function.""" - def client_wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) -> Any: + def client_wrapper( + wrapped: Callable, instance: Any, args: tuple, kwargs: dict + ) -> Any: # Create the original client client = wrapped(*args, **kwargs) @@ -305,7 +312,13 @@ def client_wrapper(wrapped: Callable, instance: Any, args: tuple, kwargs: dict) # Wrap the client instance with our instrumented proxy return InstrumentedAerospikeClient( - client, tracer, request_hook, response_hook, error_hook, capture_key, config + client, + tracer, + request_hook, + response_hook, + error_hook, + capture_key, + config, ) return client_wrapper @@ -377,7 +390,9 @@ def __init__( if isinstance(first_host, tuple) and len(first_host) >= 2: self._server_address = str(first_host[0]) self._server_port = int(first_host[1]) - elif isinstance(first_host, tuple) and len(first_host) == 1: + elif ( + isinstance(first_host, tuple) and len(first_host) == 1 + ): self._server_address = str(first_host[0]) self._server_port = 3000 except (TypeError, AttributeError, IndexError): @@ -453,7 +468,9 @@ def _set_connection_attributes(self, span: Span) -> None: if self._server_port: span.set_attribute(_SERVER_PORT_ATTR, self._server_port) - def _wrap_single_record_method(self, method: Callable, operation: str) -> Callable: + def _wrap_single_record_method( + self, method: Callable, operation: str + ) -> Callable: """Wrap a single record operation method.""" @functools.wraps(method) @@ -485,7 +502,9 @@ def wrapper(*args, **kwargs) -> Any: if self._capture_key and key_tuple and len(key_tuple) > 2: user_key = key_tuple[2] # pylint: disable=unsubscriptable-object if user_key is not None: - span.set_attribute(_DB_AEROSPIKE_KEY_ATTR, str(user_key)) + span.set_attribute( + _DB_AEROSPIKE_KEY_ATTR, str(user_key) + ) # Request hook if self._request_hook: @@ -544,8 +563,10 @@ def wrapper(*args, **kwargs) -> Any: self._set_connection_attributes(span) # Batch size - if keys and isinstance(keys, list | tuple): - span.set_attribute(_DB_OPERATION_BATCH_SIZE_ATTR, len(keys)) + if keys and isinstance(keys, (list, tuple)): + span.set_attribute( + _DB_OPERATION_BATCH_SIZE_ATTR, len(keys) + ) if self._request_hook: self._request_hook(span, operation, args, kwargs) @@ -569,7 +590,9 @@ def wrapper(*args, **kwargs) -> Any: return wrapper - def _wrap_query_scan_method(self, method: Callable, operation: str) -> Callable: + def _wrap_query_scan_method( + self, method: Callable, operation: str + ) -> Callable: """Wrap a query/scan operation method.""" @functools.wraps(method) @@ -704,7 +727,9 @@ def wrapper(*args, **kwargs) -> Any: return method(*args, **kwargs) 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 + set_name = ( + args[1] if len(args) > 1 and isinstance(args[1], str) else None + ) span_name = _generate_span_name(operation, namespace, set_name) @@ -759,7 +784,9 @@ def _get_batch_operation_name(method: str) -> str: return f"BATCH {method_upper}" -def _extract_namespace_set_from_key(key_tuple: tuple | None) -> tuple[str | None, str | None]: +def _extract_namespace_set_from_key( + key_tuple: tuple | None, +) -> tuple[str | None, str | None]: """Extract namespace and set from a single key tuple. Key format: (namespace, set, key[, digest]) @@ -772,9 +799,11 @@ def _extract_namespace_set_from_key(key_tuple: tuple | None) -> tuple[str | None return namespace, set_name -def _extract_namespace_set_from_batch(keys: list | tuple | None) -> tuple[str | None, str | None]: +def _extract_namespace_set_from_batch( + keys: list | tuple | None, +) -> tuple[str | None, str | None]: """Extract namespace and set from batch keys (uses first key).""" - if not keys or not isinstance(keys, list | tuple): + if not keys or not isinstance(keys, (list, tuple)): return None, None first_key = keys[0] @@ -783,7 +812,9 @@ def _extract_namespace_set_from_batch(keys: list | tuple | None) -> tuple[str | return None, None -def _generate_span_name(operation: str, namespace: str | None, set_name: str | None) -> str: +def _generate_span_name( + operation: str, namespace: str | None, set_name: str | None +) -> str: """Generate span name following convention: {operation} {namespace}.{set}.""" if namespace and set_name: return f"{operation} {namespace}.{set_name}" diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index c13c4e0ecb..928ef4ce6b 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -13,6 +13,7 @@ # limitations under the License. # pylint: disable=import-outside-toplevel,no-self-use,redefined-outer-name,unused-variable +# ruff: noqa: PLC0415 """Unit tests for OpenTelemetry Aerospike Instrumentation.""" @@ -41,9 +42,14 @@ def setUp(self): 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 + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + self.instrumentor = AerospikeInstrumentor() - self.instrumentor.instrument(tracer_provider=self.tracer_provider, **kwargs) + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, **kwargs + ) def _uninstrument(self): """Helper to uninstrument.""" @@ -99,13 +105,20 @@ def test_not_recording(self): 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) + 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 + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + instrumentor = AerospikeInstrumentor() instrumentor.instrument() @@ -244,7 +257,9 @@ def test_error_hook(self): hook_calls = [] def error_hook(span, operation, exception): - hook_calls.append({"operation": operation, "error": str(exception)}) + hook_calls.append( + {"operation": operation, "error": str(exception)} + ) self._instrument(error_hook=error_hook) @@ -270,7 +285,9 @@ def test_capture_key_enabled(self): 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") + self.assertEqual( + spans[0].attributes.get("db.aerospike.key"), "my_key" + ) finally: self._uninstrument() @@ -297,7 +314,10 @@ def test_no_op_tracer_provider(self): ) with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) + tracer_provider = trace.NoOpTracerProvider() instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=tracer_provider) @@ -318,7 +338,9 @@ def tracer_setup(): """Create a tracer provider with in-memory exporter.""" from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) provider = TracerProvider() exporter = InMemorySpanExporter() @@ -336,7 +358,9 @@ def test_instrumentation_dependencies(self): mock_aerospike.client = MagicMock() with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() deps = instrumentor.instrumentation_dependencies() @@ -353,7 +377,9 @@ def test_instrument_uninstrument(self, tracer_setup): mock_aerospike.client = original_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() @@ -384,7 +410,9 @@ def test_proxy_passthrough(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -420,7 +448,9 @@ def test_single_record_operation_creates_span(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -451,7 +481,9 @@ def test_batch_operation_includes_size(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -487,7 +519,9 @@ def test_error_sets_span_status(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -524,10 +558,14 @@ def request_hook(span, operation, args, kwargs): span.set_attribute("custom.attr", "test") with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider, request_hook=request_hook) + instrumentor.instrument( + tracer_provider=provider, request_hook=request_hook + ) try: client = mock_aerospike.client({}) @@ -547,7 +585,11 @@ def test_response_hook_called(self, tracer_setup): mock_aerospike = MagicMock() mock_client = MagicMock() - mock_client.get.return_value = (("test", "demo", "key1"), {"gen": 1}, {"bin": "value"}) + mock_client.get.return_value = ( + ("test", "demo", "key1"), + {"gen": 1}, + {"bin": "value"}, + ) mock_aerospike.client.return_value = mock_client hook_calls = [] @@ -556,10 +598,14 @@ def response_hook(span, operation, result): hook_calls.append({"operation": operation, "result": result}) with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider, response_hook=response_hook) + instrumentor.instrument( + tracer_provider=provider, response_hook=response_hook + ) try: client = mock_aerospike.client({}) @@ -582,13 +628,19 @@ def test_error_hook_called(self, tracer_setup): hook_calls = [] def error_hook(span, operation, exception): - hook_calls.append({"operation": operation, "error": str(exception)}) + hook_calls.append( + {"operation": operation, "error": str(exception)} + ) with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider, error_hook=error_hook) + instrumentor.instrument( + tracer_provider=provider, error_hook=error_hook + ) try: client = mock_aerospike.client({}) @@ -612,7 +664,9 @@ def test_capture_key_enabled(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider, capture_key=True) @@ -636,7 +690,9 @@ def test_capture_key_disabled_by_default(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -661,7 +717,9 @@ def test_query_operation(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -689,21 +747,34 @@ def test_udf_operation_captures_module_function(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) try: client = mock_aerospike.client({}) - client.apply(("test", "demo", "key1"), "mymodule", "myfunction", ["arg1"]) + client.apply( + ("test", "demo", "key1"), + "mymodule", + "myfunction", + ["arg1"], + ) spans = exporter.get_finished_spans() span = spans[0] assert span.name == "APPLY test.demo" - assert span.attributes.get("db.aerospike.udf.module") == "mymodule" - assert span.attributes.get("db.aerospike.udf.function") == "myfunction" + assert ( + span.attributes.get("db.aerospike.udf.module") + == "mymodule" + ) + assert ( + span.attributes.get("db.aerospike.udf.function") + == "myfunction" + ) finally: instrumentor.uninstrument() @@ -721,7 +792,9 @@ def test_result_metadata_captured(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) @@ -752,14 +825,18 @@ def test_span_name_format(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) try: client = mock_aerospike.client({}) - client.put(("production", "orders", "order123"), {"total": 100}) + client.put( + ("production", "orders", "order123"), {"total": 100} + ) spans = exporter.get_finished_spans() assert spans[0].name == "PUT production.orders" @@ -776,7 +853,9 @@ def test_span_name_without_set(self, tracer_setup): mock_aerospike.client.return_value = mock_client with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import AerospikeInstrumentor + from opentelemetry.instrumentation.aerospike import ( + AerospikeInstrumentor, + ) instrumentor = AerospikeInstrumentor() instrumentor.instrument(tracer_provider=provider) 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", From 418c3a55a07a8a464d27adf6cc0f09eb9a1f54e7 Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Thu, 29 Jan 2026 21:37:58 +0900 Subject: [PATCH 03/16] fix: improve aerospike instrumentation quality - Fix scan_apply/query_apply arg parsing with dedicated wrappers - Cache wrapped methods in __getattr__ to avoid re-wrapping - Add UDF attribute constants, remove duplicate code and dead code - Restructure tests, add missing coverage for scan/query/admin ops --- .../instrumentation/aerospike/__init__.py | 205 +++-- .../tests/test_aerospike_instrumentation.py | 709 +++++++----------- 2 files changed, 413 insertions(+), 501 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 310c46ffa5..8c7f04fda4 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -204,6 +204,8 @@ def error_hook(span, operation, exception): _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" class AerospikeInstrumentor(BaseInstrumentor): @@ -216,38 +218,6 @@ class AerospikeInstrumentor(BaseInstrumentor): factory function (aerospike.client) to instrument each client instance. """ - # Methods to instrument - _SINGLE_RECORD_METHODS = [ - "put", - "get", - "select", - "exists", - "remove", - "touch", - "operate", - "append", - "prepend", - "increment", - ] - - _BATCH_METHODS = [ - "batch_read", - "batch_write", - "batch_operate", - "batch_remove", - "batch_apply", - # Note: get_many, exists_many, select_many were removed in aerospike 17.0.0 - # Use batch_read() instead - ] - - _QUERY_SCAN_METHODS = ["query", "scan"] - - _UDF_METHODS = ["apply", "scan_apply", "query_apply"] - - _ADMIN_METHODS = ["truncate", "info_all"] - - _original_client = None - def instrumentation_dependencies(self) -> Collection[str]: """Return the dependencies required for this instrumentation.""" return _instruments @@ -356,7 +326,11 @@ class InstrumentedAerospikeClient: _QUERY_SCAN_METHODS = ["query", "scan"] - _UDF_METHODS = ["apply", "scan_apply", "query_apply"] + _UDF_METHODS = ["apply"] + + _SCAN_APPLY_METHODS = ["scan_apply"] + + _QUERY_APPLY_METHODS = ["query_apply"] _ADMIN_METHODS = ["truncate", "info_all"] @@ -399,23 +373,36 @@ def __init__( pass def __getattr__(self, name: str) -> Any: - """Proxy attribute access to the wrapped client.""" + """Proxy attribute access to the wrapped client. + + Wrapped methods are cached via object.__setattr__ so subsequent + calls bypass __getattr__ entirely. + """ attr = getattr(self._client, name) - # If it's a method we want to instrument, wrap it + # If it's a method we want to instrument, wrap and cache it if callable(attr): + wrapped = None if name in self._SINGLE_RECORD_METHODS: - return self._wrap_single_record_method(attr, name.upper()) - if name in self._BATCH_METHODS: + wrapped = self._wrap_single_record_method(attr, name.upper()) + elif name in self._BATCH_METHODS: op_name = _get_batch_operation_name(name) - return self._wrap_batch_method(attr, op_name) - if name in self._QUERY_SCAN_METHODS: - return self._wrap_query_scan_method(attr, name.upper()) - if name in self._UDF_METHODS: + wrapped = self._wrap_batch_method(attr, op_name) + elif name in self._QUERY_SCAN_METHODS: + wrapped = self._wrap_query_scan_method(attr, name.upper()) + elif name in self._UDF_METHODS: op_name = name.upper().replace("_", " ") - return self._wrap_udf_method(attr, op_name) - if name in self._ADMIN_METHODS: - return self._wrap_admin_method(attr, name.upper()) + wrapped = self._wrap_udf_method(attr, op_name) + elif name in self._SCAN_APPLY_METHODS: + wrapped = self._wrap_scan_apply_method(attr, "SCAN APPLY") + elif name in self._QUERY_APPLY_METHODS: + wrapped = self._wrap_query_apply_method(attr, "QUERY APPLY") + elif name in self._ADMIN_METHODS: + wrapped = self._wrap_admin_method(attr, name.upper()) + + if wrapped is not None: + object.__setattr__(self, name, wrapped) + return wrapped return attr @@ -686,6 +673,130 @@ def wrapper(*args, **kwargs) -> Any: return wrapper + def _wrap_scan_apply_method( + self, method: Callable, operation: str + ) -> Callable: + """Wrap scan_apply: scan_apply(namespace, set, module, function, args).""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + namespace = args[0] if args else None + set_name = args[1] if len(args) > 1 else None + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._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) + self._set_connection_attributes(span) + + # UDF info: scan_apply(ns, set, module, function, args) + 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]) + ) + + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + if self._response_hook: + self._response_hook(span, operation, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + + def _wrap_query_apply_method( + self, method: Callable, operation: str + ) -> Callable: + """Wrap query_apply: query_apply(namespace, set, predicate, module, function, args).""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + namespace = args[0] if args else None + set_name = args[1] if len(args) > 1 else None + + span_name = _generate_span_name(operation, namespace, set_name) + + with self._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) + self._set_connection_attributes(span) + + # UDF info: query_apply(ns, set, predicate, module, function, args) + 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]) + ) + + if self._request_hook: + self._request_hook(span, operation, args, kwargs) + + try: + result = method(*args, **kwargs) + + if self._response_hook: + self._response_hook(span, operation, result) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._error_hook: + self._error_hook(span, operation, exc) + + raise + + return wrapper + def _set_udf_span_attributes( self, span: Span, @@ -708,9 +819,9 @@ def _set_udf_span_attributes( # UDF info if len(args) > 1: - span.set_attribute("db.aerospike.udf.module", str(args[1])) + span.set_attribute(_DB_AEROSPIKE_UDF_MODULE_ATTR, str(args[1])) if len(args) > 2: - span.set_attribute("db.aerospike.udf.function", str(args[2])) + span.set_attribute(_DB_AEROSPIKE_UDF_FUNCTION_ATTR, str(args[2])) # Optional: capture key if self._capture_key and key_tuple and len(key_tuple) > 2: @@ -779,8 +890,6 @@ def _get_batch_operation_name(method: str) -> str: method_upper = method.upper() if method_upper.startswith("BATCH_"): return f"BATCH {method_upper[6:]}" - if method_upper.endswith("_MANY"): - return f"BATCH {method_upper[:-5]}" return f"BATCH {method_upper}" diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index 928ef4ce6b..9e3d290acb 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -20,8 +20,6 @@ from unittest import mock from unittest.mock import MagicMock, patch -import pytest - from opentelemetry import trace from opentelemetry.instrumentation.utils import suppress_instrumentation from opentelemetry.test.test_base import TestBase @@ -331,33 +329,9 @@ def test_no_op_tracer_provider(self): finally: instrumentor.uninstrument() - -# Legacy pytest-style tests for backward compatibility -@pytest.fixture -def tracer_setup(): - """Create a tracer provider with in-memory exporter.""" - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, - ) - - provider = TracerProvider() - exporter = InMemorySpanExporter() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - return provider, exporter - - -class TestAerospikeInstrumentorUnit: - """Unit tests for AerospikeInstrumentor using mocks.""" - def test_instrumentation_dependencies(self): """Test that dependencies are correctly specified.""" - # Create a mock aerospike module - mock_aerospike = MagicMock() - mock_aerospike.client = MagicMock() - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): + with patch.dict("sys.modules", {"aerospike": self.mock_aerospike}): from opentelemetry.instrumentation.aerospike import ( AerospikeInstrumentor, ) @@ -365,506 +339,335 @@ def test_instrumentation_dependencies(self): instrumentor = AerospikeInstrumentor() deps = instrumentor.instrumentation_dependencies() - assert len(deps) == 1 - assert "aerospike >= 17.0.0" in deps[0] - - def test_instrument_uninstrument(self, tracer_setup): - """Test instrument and uninstrument cycle.""" - provider, exporter = tracer_setup - - mock_aerospike = MagicMock() - original_client = MagicMock() - mock_aerospike.client = original_client + self.assertEqual(len(deps), 1) + self.assertIn("aerospike >= 17.0.0", deps[0]) - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + 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() - instrumentor = AerospikeInstrumentor() + try: + client = self.mock_aerospike.client({}) - # Instrument - instrumentor.instrument(tracer_provider=provider) + client.connect() + self.mock_client.connect.assert_called_once() - # Verify client function was wrapped (wrapped function has __wrapped__) - assert hasattr(mock_aerospike.client, "__wrapped__") + self.assertTrue(client.is_connected()) - # Uninstrument - instrumentor.uninstrument() + client.close() + self.mock_client.close.assert_called_once() + finally: + self._uninstrument() - # Verify client function was unwrapped - assert not hasattr(mock_aerospike.client, "__wrapped__") + def test_query_operation(self): + """Test query operation creates correct span.""" + mock_query = MagicMock() + self.mock_client.query.return_value = mock_query + self._instrument() + try: + client = self.mock_aerospike.client({}) + client.query("test", "users") -class TestInstrumentedAerospikeClient: - """Unit tests for InstrumentedAerospikeClient.""" + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - def test_proxy_passthrough(self, tracer_setup): - """Test that non-instrumented methods pass through.""" - provider, exporter = tracer_setup + span = spans[0] + self.assertEqual(span.name, "QUERY test.users") + self.assertEqual(span.attributes["db.operation.name"], "QUERY") + finally: + self._uninstrument() - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.is_connected.return_value = True - mock_client.some_other_method.return_value = "result" - mock_aerospike.client.return_value = mock_client + 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() - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, + try: + client = self.mock_aerospike.client({}) + client.apply( + ("test", "demo", "key1"), + "mymodule", + "myfunction", + ["arg1"], ) - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) - - try: - # Get instrumented client - client = mock_aerospike.client({}) - - # Test connect method - client.connect() - mock_client.connect.assert_called_once() - - # Test is_connected - assert client.is_connected() is True - - # Test close - client.close() - mock_client.close.assert_called_once() - finally: - instrumentor.uninstrument() + spans = self.memory_exporter.get_finished_spans() + span = spans[0] - def test_single_record_operation_creates_span(self, tracer_setup): - """Test that single record operations create spans.""" - provider, exporter = tracer_setup + 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() - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.get.return_value = ( + 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": 1, "ttl": 100}, - {"bin1": "value1"}, + {"gen": 5, "ttl": 3600}, + {"bin": "value"}, ) - mock_aerospike.client.return_value = mock_client - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) - - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) - - try: - client = mock_aerospike.client({}) - client.get(("test", "demo", "key1")) - - spans = exporter.get_finished_spans() - assert len(spans) == 1 + self._instrument() - span = spans[0] - assert span.name == "GET test.demo" - assert span.attributes["db.system"] == "aerospike" - assert span.attributes["db.namespace"] == "test" - assert span.attributes["db.collection.name"] == "demo" - assert span.attributes["db.operation.name"] == "GET" - finally: - instrumentor.uninstrument() + try: + client = self.mock_aerospike.client({}) + client.get(("test", "demo", "key1")) - def test_batch_operation_includes_size(self, tracer_setup): - """Test that batch operations include batch size attribute.""" - provider, exporter = tracer_setup + spans = self.memory_exporter.get_finished_spans() + span = spans[0] - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.batch_read.return_value = [] - mock_aerospike.client.return_value = mock_client + self.assertEqual(span.attributes.get("db.aerospike.generation"), 5) + self.assertEqual(span.attributes.get("db.aerospike.ttl"), 3600) + finally: + self._uninstrument() - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + def test_span_name_format(self): + """Test span name follows {operation} {namespace}.{set} format.""" + self.mock_client.put.return_value = None + self._instrument() - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) + try: + client = self.mock_aerospike.client({}) + client.put(("production", "orders", "order123"), {"total": 100}) - try: - client = mock_aerospike.client({}) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(spans[0].name, "PUT production.orders") + finally: + self._uninstrument() - keys = [ - ("test", "demo", "key1"), - ("test", "demo", "key2"), - ("test", "demo", "key3"), - ] - client.batch_read(keys) + def test_span_name_without_set(self): + """Test span name when set is None.""" + self.mock_client.put.return_value = None + self._instrument() - spans = exporter.get_finished_spans() - assert len(spans) == 1 + try: + client = self.mock_aerospike.client({}) + client.put(("test", None, "key1"), {"bin": "value"}) - span = spans[0] - assert span.attributes["db.operation.batch.size"] == 3 - finally: - instrumentor.uninstrument() + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(spans[0].name, "PUT test") + finally: + self._uninstrument() - def test_error_sets_span_status(self, tracer_setup): - """Test that errors set span status correctly.""" - provider, exporter = tracer_setup + 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() - mock_aerospike = MagicMock() - mock_client = MagicMock() + try: + client = self.mock_aerospike.client({}) + client.scan_apply("test", "demo", "mymodule", "myfunc", ["arg1"]) - error = ValueError("Record not found") - error.code = 2 # KEY_NOT_FOUND - mock_client.get.side_effect = error - mock_aerospike.client.return_value = mock_client + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, + 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" ) - - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) - - try: - client = mock_aerospike.client({}) - - with pytest.raises(ValueError): - client.get(("test", "demo", "key1")) - - spans = exporter.get_finished_spans() - assert len(spans) == 1 - - span = spans[0] - assert span.status.status_code == StatusCode.ERROR - assert span.attributes["error.type"] == "ValueError" - assert span.attributes["db.response.status_code"] == "2" - finally: - instrumentor.uninstrument() - - def test_request_hook_called(self, tracer_setup): - """Test that request hook is called.""" - provider, exporter = tracer_setup - - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.put.return_value = None - mock_aerospike.client.return_value = mock_client - - hook_calls = [] - - def request_hook(span, operation, args, kwargs): - hook_calls.append({"operation": operation}) - span.set_attribute("custom.attr", "test") - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, + self.assertEqual( + span.attributes["db.aerospike.udf.module"], "mymodule" ) - - instrumentor = AerospikeInstrumentor() - instrumentor.instrument( - tracer_provider=provider, request_hook=request_hook + self.assertEqual( + span.attributes["db.aerospike.udf.function"], "myfunc" ) + finally: + self._uninstrument() - try: - client = mock_aerospike.client({}) - client.put(("test", "demo", "key1"), {"bin": "value"}) - - assert len(hook_calls) == 1 - assert hook_calls[0]["operation"] == "PUT" - - spans = exporter.get_finished_spans() - assert spans[0].attributes.get("custom.attr") == "test" - finally: - instrumentor.uninstrument() - - def test_response_hook_called(self, tracer_setup): - """Test that response hook is called.""" - provider, exporter = tracer_setup - - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.get.return_value = ( - ("test", "demo", "key1"), - {"gen": 1}, - {"bin": "value"}, - ) - mock_aerospike.client.return_value = mock_client - - hook_calls = [] - - def response_hook(span, operation, result): - hook_calls.append({"operation": operation, "result": result}) - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + 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() - instrumentor = AerospikeInstrumentor() - instrumentor.instrument( - tracer_provider=provider, response_hook=response_hook + try: + client = self.mock_aerospike.client({}) + predicate = MagicMock() + client.query_apply( + "test", "demo", predicate, "mymodule", "myfunc", ["arg1"] ) - try: - client = mock_aerospike.client({}) - client.get(("test", "demo", "key1")) - - assert len(hook_calls) == 1 - assert hook_calls[0]["operation"] == "GET" - finally: - instrumentor.uninstrument() - - def test_error_hook_called(self, tracer_setup): - """Test that error hook is called on exception.""" - provider, exporter = tracer_setup - - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.get.side_effect = RuntimeError("Test error") - mock_aerospike.client.return_value = mock_client - - hook_calls = [] + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - def error_hook(span, operation, exception): - hook_calls.append( - {"operation": operation, "error": str(exception)} + 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" ) - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, + self.assertEqual( + span.attributes["db.aerospike.udf.module"], "mymodule" ) - - instrumentor = AerospikeInstrumentor() - instrumentor.instrument( - tracer_provider=provider, error_hook=error_hook + self.assertEqual( + span.attributes["db.aerospike.udf.function"], "myfunc" ) + finally: + self._uninstrument() - try: - client = mock_aerospike.client({}) + def test_scan_operation(self): + """Test scan operation creates correct span.""" + mock_scan = MagicMock() + self.mock_client.scan.return_value = mock_scan + self._instrument() - with pytest.raises(RuntimeError): - client.get(("test", "demo", "key1")) + try: + client = self.mock_aerospike.client({}) + client.scan("test", "demo") - assert len(hook_calls) == 1 - assert hook_calls[0]["operation"] == "GET" - assert "Test error" in hook_calls[0]["error"] - finally: - instrumentor.uninstrument() + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - def test_capture_key_enabled(self, tracer_setup): - """Test that key is captured when enabled.""" - provider, exporter = tracer_setup + span = spans[0] + self.assertEqual(span.name, "SCAN test.demo") + self.assertEqual(span.attributes["db.operation.name"], "SCAN") + finally: + self._uninstrument() - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.put.return_value = None - mock_aerospike.client.return_value = mock_client + def test_truncate_admin_method(self): + """Test truncate admin operation creates correct span.""" + self.mock_client.truncate.return_value = None + self._instrument() - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + try: + client = self.mock_aerospike.client({}) + client.truncate("test", "demo", 0) - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider, capture_key=True) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - try: - client = mock_aerospike.client({}) - client.put(("test", "demo", "my_key"), {"bin": "value"}) + span = spans[0] + self.assertEqual(span.name, "TRUNCATE test.demo") + self.assertEqual(span.attributes["db.operation.name"], "TRUNCATE") + finally: + self._uninstrument() - spans = exporter.get_finished_spans() - assert spans[0].attributes.get("db.aerospike.key") == "my_key" - finally: - instrumentor.uninstrument() + def test_info_all_admin_method(self): + """Test info_all admin operation creates correct span.""" + self.mock_client.info_all.return_value = {} + self._instrument() - def test_capture_key_disabled_by_default(self, tracer_setup): - """Test that key is not captured by default.""" - provider, exporter = tracer_setup + try: + client = self.mock_aerospike.client({}) + client.info_all("status") - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.put.return_value = None - mock_aerospike.client.return_value = mock_client + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + span = spans[0] + self.assertEqual(span.attributes["db.operation.name"], "INFO_ALL") + finally: + self._uninstrument() - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) + def test_connect_returns_self(self): + """Test that connect() returns the instrumented client (self).""" + self._instrument() - try: - client = mock_aerospike.client({}) - client.put(("test", "demo", "secret_key"), {"bin": "value"}) + try: + client = self.mock_aerospike.client({}) + result = client.connect() - spans = exporter.get_finished_spans() - assert "db.aerospike.key" not in spans[0].attributes - finally: - instrumentor.uninstrument() + self.assertIs(result, client) + finally: + self._uninstrument() - def test_query_operation(self, tracer_setup): - """Test query operation creates correct span.""" - provider, exporter = tracer_setup + 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] - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_query = MagicMock() - mock_client.query.return_value = mock_query - mock_aerospike.client.return_value = mock_client + self._instrument() - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + try: + client = self.mock_aerospike.client({}) + client.connect() - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) + self.assertEqual(client._server_address, "192.168.1.1") + self.assertEqual(client._server_port, 3000) + finally: + self._uninstrument() - try: - client = mock_aerospike.client({}) - client.query("test", "users") + def test_update_server_info_no_nodes(self): + """Test that server info gracefully handles no nodes.""" + self.mock_client.get_nodes.return_value = [] - spans = exporter.get_finished_spans() - assert len(spans) == 1 + config = {"hosts": [("127.0.0.1", 3000)]} + self._instrument() - span = spans[0] - assert span.name == "QUERY test.users" - assert span.attributes["db.operation.name"] == "QUERY" - finally: - instrumentor.uninstrument() + try: + client = self.mock_aerospike.client(config) + client.connect() - def test_udf_operation_captures_module_function(self, tracer_setup): - """Test UDF operation captures module and function names.""" - provider, exporter = tracer_setup + # Should keep the config-based values + self.assertEqual(client._server_address, "127.0.0.1") + self.assertEqual(client._server_port, 3000) + finally: + self._uninstrument() - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.apply.return_value = "result" - mock_aerospike.client.return_value = mock_client + def test_batch_empty_keys(self): + """Test batch operation with empty keys list.""" + self.mock_client.batch_read.return_value = [] + self._instrument() - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + try: + client = self.mock_aerospike.client({}) + client.batch_read([]) - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) - try: - client = mock_aerospike.client({}) - client.apply( - ("test", "demo", "key1"), - "mymodule", - "myfunction", - ["arg1"], - ) + span = spans[0] + self.assertEqual(span.name, "BATCH READ") + self.assertNotIn("db.namespace", span.attributes) + finally: + self._uninstrument() - spans = exporter.get_finished_spans() - span = spans[0] + 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() - assert span.name == "APPLY test.demo" - assert ( - span.attributes.get("db.aerospike.udf.module") - == "mymodule" - ) - assert ( - span.attributes.get("db.aerospike.udf.function") - == "myfunction" - ) - finally: - instrumentor.uninstrument() + try: + client = self.mock_aerospike.client({}) + client.get("not_a_tuple") - def test_result_metadata_captured(self, tracer_setup): - """Test that generation and TTL are captured from results.""" - provider, exporter = tracer_setup + 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() - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.get.return_value = ( + def test_wrapper_caching(self): + """Test that __getattr__ caches wrapped methods.""" + self.mock_client.get.return_value = ( ("test", "demo", "key1"), - {"gen": 5, "ttl": 3600}, - {"bin": "value"}, + {"gen": 1, "ttl": 100}, + {"bin1": "value1"}, ) - mock_aerospike.client.return_value = mock_client - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) - - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) - - try: - client = mock_aerospike.client({}) - client.get(("test", "demo", "key1")) - - spans = exporter.get_finished_spans() - span = spans[0] - - assert span.attributes.get("db.aerospike.generation") == 5 - assert span.attributes.get("db.aerospike.ttl") == 3600 - finally: - instrumentor.uninstrument() - - -class TestSpanNaming: - """Tests for span naming conventions.""" - - def test_span_name_format(self, tracer_setup): - """Test span name follows {operation} {namespace}.{set} format.""" - provider, exporter = tracer_setup - - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.put.return_value = None - mock_aerospike.client.return_value = mock_client - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) - - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) - - try: - client = mock_aerospike.client({}) - client.put( - ("production", "orders", "order123"), {"total": 100} - ) - - spans = exporter.get_finished_spans() - assert spans[0].name == "PUT production.orders" - finally: - instrumentor.uninstrument() - - def test_span_name_without_set(self, tracer_setup): - """Test span name when set is None.""" - provider, exporter = tracer_setup - - mock_aerospike = MagicMock() - mock_client = MagicMock() - mock_client.put.return_value = None - mock_aerospike.client.return_value = mock_client - - with patch.dict("sys.modules", {"aerospike": mock_aerospike}): - from opentelemetry.instrumentation.aerospike import ( - AerospikeInstrumentor, - ) + self._instrument() - instrumentor = AerospikeInstrumentor() - instrumentor.instrument(tracer_provider=provider) + try: + client = self.mock_aerospike.client({}) - try: - client = mock_aerospike.client({}) - client.put(("test", None, "key1"), {"bin": "value"}) + # First call triggers __getattr__ and caches the wrapper + wrapper1 = client.get + # Second call should return the cached wrapper + wrapper2 = client.get - spans = exporter.get_finished_spans() - assert spans[0].name == "PUT test" - finally: - instrumentor.uninstrument() + self.assertIs(wrapper1, wrapper2) + finally: + self._uninstrument() From 1b45a79ec961b2288511d40edc4f0dd1cecc0b4b Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Fri, 30 Jan 2026 10:39:02 +0900 Subject: [PATCH 04/16] refactor: consolidate aerospike wrapper methods into generic traced wrapper - Replace 7 duplicate wrapper methods with single _create_traced_wrapper - Fix IPv6 address parsing in _update_server_info_from_nodes - Remove unused _original_client field and dead import - Fix redundant condition in _set_result_attributes - Change method lists from list to frozenset for O(1) lookup --- .../instrumentation/aerospike/__init__.py | 610 ++++++------------ 1 file changed, 198 insertions(+), 412 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 8c7f04fda4..2350e1e90e 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -224,8 +224,6 @@ def instrumentation_dependencies(self) -> Collection[str]: def _instrument(self, **kwargs: Any) -> None: """Instrument Aerospike client factory function.""" - import aerospike # noqa: PLC0415 # pylint: disable=import-outside-toplevel - tracer_provider = kwargs.get("tracer_provider") tracer = trace.get_tracer( __name__, @@ -239,9 +237,6 @@ def _instrument(self, **kwargs: Any) -> None: error_hook = kwargs.get("error_hook") capture_key = kwargs.get("capture_key", False) - # Store original client function - self._original_client = aerospike.client # pylint: disable=c-extension-no-member - # Wrap the client factory function wrap_function_wrapper( "aerospike", @@ -301,38 +296,40 @@ class InstrumentedAerospikeClient: OpenTelemetry tracing to all database operations. """ - _SINGLE_RECORD_METHODS = [ - "put", - "get", - "select", - "exists", - "remove", - "touch", - "operate", - "append", - "prepend", - "increment", - ] + _SINGLE_RECORD_METHODS = frozenset( + { + "put", + "get", + "select", + "exists", + "remove", + "touch", + "operate", + "append", + "prepend", + "increment", + } + ) - _BATCH_METHODS = [ - "batch_read", - "batch_write", - "batch_operate", - "batch_remove", - "batch_apply", - # Note: get_many, exists_many, select_many were removed in aerospike 17.0.0 - # Use batch_read() instead - ] + _BATCH_METHODS = frozenset( + { + "batch_read", + "batch_write", + "batch_operate", + "batch_remove", + "batch_apply", + } + ) - _QUERY_SCAN_METHODS = ["query", "scan"] + _QUERY_SCAN_METHODS = frozenset({"query", "scan"}) - _UDF_METHODS = ["apply"] + _UDF_METHODS = frozenset({"apply"}) - _SCAN_APPLY_METHODS = ["scan_apply"] + _SCAN_APPLY_METHODS = frozenset({"scan_apply"}) - _QUERY_APPLY_METHODS = ["query_apply"] + _QUERY_APPLY_METHODS = frozenset({"query_apply"}) - _ADMIN_METHODS = ["truncate", "info_all"] + _ADMIN_METHODS = frozenset({"truncate", "info_all"}) def __init__( self, @@ -384,21 +381,53 @@ def __getattr__(self, name: str) -> Any: if callable(attr): wrapped = None if name in self._SINGLE_RECORD_METHODS: - wrapped = self._wrap_single_record_method(attr, name.upper()) + wrapped = self._create_traced_wrapper( + attr, + name.upper(), + _ns_set_from_key_arg, + self._extra_attrs_capture_key, + _set_result_attributes, + ) elif name in self._BATCH_METHODS: - op_name = _get_batch_operation_name(name) - wrapped = self._wrap_batch_method(attr, op_name) + wrapped = self._create_traced_wrapper( + attr, + _get_batch_operation_name(name), + _ns_set_from_batch_arg, + _extra_attrs_batch_size, + ) elif name in self._QUERY_SCAN_METHODS: - wrapped = self._wrap_query_scan_method(attr, name.upper()) + wrapped = self._create_traced_wrapper( + attr, + name.upper(), + _ns_set_from_positional_args, + ) elif name in self._UDF_METHODS: - op_name = name.upper().replace("_", " ") - wrapped = self._wrap_udf_method(attr, op_name) + wrapped = self._create_traced_wrapper( + attr, + name.upper().replace("_", " "), + _ns_set_from_key_arg, + self._extra_attrs_udf_apply, + ) elif name in self._SCAN_APPLY_METHODS: - wrapped = self._wrap_scan_apply_method(attr, "SCAN APPLY") + wrapped = self._create_traced_wrapper( + attr, + "SCAN APPLY", + _ns_set_from_positional_args, + _extra_attrs_scan_apply_udf, + ) elif name in self._QUERY_APPLY_METHODS: - wrapped = self._wrap_query_apply_method(attr, "QUERY APPLY") + wrapped = self._create_traced_wrapper( + attr, + "QUERY APPLY", + _ns_set_from_positional_args, + _extra_attrs_query_apply_udf, + ) elif name in self._ADMIN_METHODS: - wrapped = self._wrap_admin_method(attr, name.upper()) + wrapped = self._create_traced_wrapper( + attr, + name.upper(), + _ns_set_from_admin_args, + ) if wrapped is not None: object.__setattr__(self, name, wrapped) @@ -423,18 +452,10 @@ def _update_server_info_from_nodes(self) -> None: node = nodes[0] if not hasattr(node, "name"): return - # node.name is typically "host:port" or "host" - node_name = str(node.name) - if ":" in node_name: - host, port = node_name.rsplit(":", 1) + host, port = _parse_host_port(str(node.name)) + if host: self._server_address = host - try: - self._server_port = int(port) - except ValueError: - self._server_port = 3000 - else: - self._server_address = node_name - self._server_port = 3000 + self._server_port = port or 3000 except (TypeError, AttributeError, IndexError): # If we can't get node info, keep the config-based values pass @@ -449,243 +470,35 @@ def is_connected(self) -> bool: def _set_connection_attributes(self, span: Span) -> None: """Set connection-related attributes on span.""" - # Use cached server address if available 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) - def _wrap_single_record_method( - self, method: Callable, operation: str - ) -> Callable: - """Wrap a single record operation method.""" - - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - - key_tuple = args[0] if args else None - namespace, set_name = _extract_namespace_set_from_key(key_tuple) - - span_name = _generate_span_name(operation, namespace, set_name) - - with self._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) - self._set_connection_attributes(span) - - # Optional: capture key - if self._capture_key and key_tuple and len(key_tuple) > 2: - user_key = key_tuple[2] # pylint: disable=unsubscriptable-object - if user_key is not None: - span.set_attribute( - _DB_AEROSPIKE_KEY_ATTR, str(user_key) - ) - - # Request hook - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - - try: - result = method(*args, **kwargs) - - # Response hook - if self._response_hook: - self._response_hook(span, operation, result) - - # Set generation/TTL from result - if span.is_recording(): - _set_result_attributes(span, result) - - return result - - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._error_hook: - self._error_hook(span, operation, exc) - - raise - - return wrapper - - def _wrap_batch_method(self, method: Callable, operation: str) -> Callable: - """Wrap a batch operation method.""" - - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - - keys = args[0] if args else None - namespace, set_name = _extract_namespace_set_from_batch(keys) - - span_name = _generate_span_name(operation, namespace, set_name) - - with self._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) - self._set_connection_attributes(span) - - # Batch size - if keys and isinstance(keys, (list, tuple)): - span.set_attribute( - _DB_OPERATION_BATCH_SIZE_ATTR, len(keys) - ) - - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - - try: - result = method(*args, **kwargs) - - if self._response_hook: - self._response_hook(span, operation, result) - - return result - - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._error_hook: - self._error_hook(span, operation, exc) - - raise - - return wrapper - - def _wrap_query_scan_method( - self, method: Callable, operation: str - ) -> Callable: - """Wrap a query/scan operation method.""" - - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - - namespace = args[0] if args else None - set_name = args[1] if len(args) > 1 else None - - span_name = _generate_span_name(operation, namespace, set_name) - - with self._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) - self._set_connection_attributes(span) - - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - - try: - result = method(*args, **kwargs) - - if self._response_hook: - self._response_hook(span, operation, result) - - return result - - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._error_hook: - self._error_hook(span, operation, exc) - - raise - - return wrapper - - def _wrap_udf_method(self, method: Callable, operation: str) -> Callable: - """Wrap a UDF operation method.""" - - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - - key_tuple = args[0] if args else None - namespace, set_name = _extract_namespace_set_from_key(key_tuple) - - span_name = _generate_span_name(operation, namespace, set_name) - - with self._tracer.start_as_current_span( - span_name, - kind=SpanKind.CLIENT, - ) as span: - if span.is_recording(): - self._set_udf_span_attributes( - span, operation, namespace, set_name, args, key_tuple - ) - - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - - try: - result = method(*args, **kwargs) - - if self._response_hook: - self._response_hook(span, operation, result) - - return result - - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._error_hook: - self._error_hook(span, operation, exc) - - raise - - return wrapper - - def _wrap_scan_apply_method( - self, method: Callable, operation: str + def _create_traced_wrapper( + self, + method: Callable, + operation: str, + extract_ns_set: Callable[[tuple], tuple[str | None, str | None]], + set_extra_attrs: Callable[[Span, tuple], None] | None = None, + set_result_attrs: Callable[[Span, Any], None] | None = None, ) -> Callable: - """Wrap scan_apply: scan_apply(namespace, set, module, function, args).""" + """Create a traced wrapper for an Aerospike client method. + + Args: + method: The original client method to wrap. + operation: Operation name for the span (e.g. "GET", "PUT"). + extract_ns_set: Function to extract (namespace, set_name) from args. + set_extra_attrs: Optional function to set additional span attributes. + set_result_attrs: Optional function to set attributes from the result. + """ @functools.wraps(method) def wrapper(*args, **kwargs) -> Any: if not is_instrumentation_enabled(): return method(*args, **kwargs) - namespace = args[0] if args else None - set_name = args[1] if len(args) > 1 else None - + namespace, set_name = extract_ns_set(args) span_name = _generate_span_name(operation, namespace, set_name) with self._tracer.start_as_current_span( @@ -703,15 +516,8 @@ def wrapper(*args, **kwargs) -> Any: span.set_attribute(_DB_OPERATION_NAME_ATTR, operation) self._set_connection_attributes(span) - # UDF info: scan_apply(ns, set, module, function, args) - 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]) - ) + if set_extra_attrs: + set_extra_attrs(span, args) if self._request_hook: self._request_hook(span, operation, args, kwargs) @@ -722,67 +528,8 @@ def wrapper(*args, **kwargs) -> Any: if self._response_hook: self._response_hook(span, operation, result) - return result - - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._error_hook: - self._error_hook(span, operation, exc) - - raise - - return wrapper - - def _wrap_query_apply_method( - self, method: Callable, operation: str - ) -> Callable: - """Wrap query_apply: query_apply(namespace, set, predicate, module, function, args).""" - - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - - namespace = args[0] if args else None - set_name = args[1] if len(args) > 1 else None - - span_name = _generate_span_name(operation, namespace, set_name) - - with self._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) - self._set_connection_attributes(span) - - # UDF info: query_apply(ns, set, predicate, module, function, args) - 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]) - ) - - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - - try: - result = method(*args, **kwargs) - - if self._response_hook: - self._response_hook(span, operation, result) + if set_result_attrs and span.is_recording(): + set_result_attrs(span, result) return result @@ -797,92 +544,94 @@ def wrapper(*args, **kwargs) -> Any: return wrapper - def _set_udf_span_attributes( - self, - span: Span, - operation: str, - namespace: str | None, - set_name: str | None, - args: tuple, - key_tuple: tuple | None, - ) -> None: - """Set span attributes for UDF operations.""" - 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) - self._set_connection_attributes(span) - - # UDF info + # -- Extra attribute setters (bound methods for self access) -- + + def _extra_attrs_capture_key(self, span: Span, args: tuple) -> None: + """Set key capture attribute for single record methods.""" + 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_udf_apply(self, span: Span, args: tuple) -> None: + """Set UDF module/function and key capture for apply method.""" 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) - # Optional: capture key - if self._capture_key and key_tuple and len(key_tuple) > 2: - user_key = key_tuple[2] # pylint: disable=unsubscriptable-object - if user_key is not None: - span.set_attribute(_DB_AEROSPIKE_KEY_ATTR, str(user_key)) - def _wrap_admin_method(self, method: Callable, operation: str) -> Callable: - """Wrap an admin operation method.""" +# -- Namespace/set extraction functions -- - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - 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 - ) +def _ns_set_from_key_arg( + args: tuple, +) -> tuple[str | None, str | None]: + """Extract namespace/set from args where args[0] is a key tuple.""" + key_tuple = args[0] if args else None + return _extract_namespace_set_from_key(key_tuple) - span_name = _generate_span_name(operation, namespace, set_name) - with self._tracer.start_as_current_span( - span_name, - kind=SpanKind.CLIENT, - ) as span: - if span.is_recording(): - span.set_attribute(_DB_SYSTEM_ATTR, _DB_SYSTEM) +def _ns_set_from_batch_arg( + args: tuple, +) -> tuple[str | None, str | None]: + """Extract namespace/set from args where args[0] is a batch keys list.""" + keys = args[0] if args else None + return _extract_namespace_set_from_batch(keys) - 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) - self._set_connection_attributes(span) +def _ns_set_from_positional_args( + args: tuple, +) -> tuple[str | None, str | None]: + """Extract namespace/set directly from args[0] and args[1].""" + namespace = args[0] if args else None + set_name = args[1] if len(args) > 1 else None + return namespace, set_name - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - try: - result = method(*args, **kwargs) +def _ns_set_from_admin_args( + args: tuple, +) -> tuple[str | None, str | None]: + """Extract namespace/set from admin args with type checking.""" + 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 - if self._response_hook: - self._response_hook(span, operation, result) - return result +# -- Extra attribute setters (module-level, no self needed) -- - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - if self._error_hook: - self._error_hook(span, operation, exc) +def _extra_attrs_batch_size(span: Span, args: tuple) -> None: + """Set batch size attribute.""" + keys = args[0] if args else None + if keys and isinstance(keys, (list, tuple)): + span.set_attribute(_DB_OPERATION_BATCH_SIZE_ATTR, len(keys)) - raise - return wrapper +def _extra_attrs_scan_apply_udf(span: Span, args: tuple) -> None: + """Set UDF attributes for scan_apply(ns, set, module, function, args).""" + 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: + """Set UDF attributes for query_apply(ns, set, predicate, module, function, args).""" + 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])) -# Helper functions +# -- Helper functions -- def _get_batch_operation_name(method: str) -> str: @@ -903,7 +652,7 @@ def _extract_namespace_set_from_key( if not key_tuple or not isinstance(key_tuple, tuple): return None, None - namespace = key_tuple[0] if len(key_tuple) > 0 else None + namespace = key_tuple[0] set_name = key_tuple[1] if len(key_tuple) > 1 else None return namespace, set_name @@ -921,6 +670,43 @@ def _extract_namespace_set_from_batch( return None, None +def _parse_host_port(address: str) -> tuple[str | None, int | None]: + """Parse host:port string, handling IPv6 bracket notation. + + Examples: + "192.168.1.1:3000" -> ("192.168.1.1", 3000) + "[::1]:3000" -> ("::1", 3000) + "hostname" -> ("hostname", None) + "2001:db8::1" -> ("2001:db8::1", None) # IPv6 without port + """ + if not address: + return None, None + + # [IPv6]:port + if address.startswith("["): + bracket_end = address.find("]") + if bracket_end < 0: + return address, None + host = address[1:bracket_end] + rest = address[bracket_end + 1 :] + if rest.startswith(":"): + try: + return host, int(rest[1:]) + except ValueError: + return host, None + return host, None + + # host:port — only split if exactly one colon to avoid IPv6 misparse + if address.count(":") == 1: + host, port_str = address.split(":", 1) + try: + return host, int(port_str) + except ValueError: + return address, None + + return address, None + + def _generate_span_name( operation: str, namespace: str | None, set_name: str | None ) -> str: @@ -936,7 +722,7 @@ def _set_result_attributes(span: Span, result: Any) -> None: """Set attributes from operation result.""" if isinstance(result, tuple) and len(result) >= 2: # Format: (key, meta, bins) or (key, meta) - meta = result[1] if len(result) > 1 else None + meta = result[1] if isinstance(meta, dict): if "gen" in meta: span.set_attribute(_DB_AEROSPIKE_GENERATION_ATTR, meta["gen"]) From a19a21d2c08712c0f92fcc345b83daf564891393 Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Fri, 30 Jan 2026 10:39:45 +0900 Subject: [PATCH 05/16] test: add aerospike docker-based functional tests - Add otaerospike service to docker-compose.yml - Add aerospike connection check to check_availability.py - Add aerospike>=17.0.0 to test-requirements.txt - Add functional tests covering put/get, exists, remove, batch_read, query, scan, apply, scan_apply, truncate, error handling, and parent span propagation - Add test_udf.lua for UDF test scenarios --- .../aerospike/test_aerospike_functional.py | 278 ++++++++++++++++++ .../tests/aerospike/test_udf.lua | 7 + .../tests/check_availability.py | 12 + .../tests/docker-compose.yml | 4 + .../tests/test-requirements.txt | 1 + 5 files changed, 302 insertions(+) create mode 100644 tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py create mode 100644 tests/opentelemetry-docker-tests/tests/aerospike/test_udf.lua 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..a70fc31848 --- /dev/null +++ b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py @@ -0,0 +1,278 @@ +# 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"], "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(self): + """Test query object creation creates a span.""" + self._client.query("test", "demo") + + 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") + + def test_scan(self): + """Test scan object creation creates a span.""" + self._client.scan("test", "demo") + + 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") + + 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 From 3faa18f4de1d3d21c7360d1fc8ddfa39129d0a37 Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Fri, 30 Jan 2026 10:53:07 +0900 Subject: [PATCH 06/16] fix: resolve pylint warnings for aerospike instrumentation - Reduce return statements in _parse_host_port from 8 to 3 - Suppress too-many-public-methods on test class --- .../instrumentation/aerospike/__init__.py | 33 +++++++++---------- .../tests/test_aerospike_instrumentation.py | 2 +- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 2350e1e90e..0ba4a39837 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -682,29 +682,28 @@ def _parse_host_port(address: str) -> tuple[str | None, int | None]: if not address: return None, None + host, port = address, None + # [IPv6]:port if address.startswith("["): bracket_end = address.find("]") - if bracket_end < 0: - return address, None - host = address[1:bracket_end] - rest = address[bracket_end + 1 :] - if rest.startswith(":"): - try: - return host, int(rest[1:]) - except ValueError: - return host, None - return host, None - - # host:port — only split if exactly one colon to avoid IPv6 misparse - if address.count(":") == 1: - host, port_str = address.split(":", 1) + 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 — only split if exactly one colon to avoid IPv6 misparse + h, port_str = address.split(":", 1) try: - return host, int(port_str) + host, port = h, int(port_str) except ValueError: - return address, None + pass - return address, None + return host, port def _generate_span_name( diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index 9e3d290acb..a29446bb66 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -26,7 +26,7 @@ from opentelemetry.trace import SpanKind, StatusCode -class TestAerospikeInstrumentation(TestBase): +class TestAerospikeInstrumentation(TestBase): # pylint: disable=too-many-public-methods """Unit tests using TestBase for consistent test infrastructure.""" instrumentor = None # Will be set in _instrument() From 75765e6fdc2225b08414cd6f0f1c39681544eff5 Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Fri, 30 Jan 2026 11:02:29 +0900 Subject: [PATCH 07/16] fix: rename single-char variable to satisfy pylint naming convention --- .../src/opentelemetry/instrumentation/aerospike/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 0ba4a39837..c140d49d4f 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -697,9 +697,9 @@ def _parse_host_port(address: str) -> tuple[str | None, int | None]: pass elif address.count(":") == 1: # host:port — only split if exactly one colon to avoid IPv6 misparse - h, port_str = address.split(":", 1) + host, port_str = address.split(":", 1) try: - host, port = h, int(port_str) + port = int(port_str) except ValueError: pass From f098bd1d3fe853265904a7ccb6df799c722de659 Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Sat, 31 Jan 2026 13:01:45 +0900 Subject: [PATCH 08/16] fix: defer query/scan tracing to execution methods instead of factory calls client.query() and client.scan() are factory methods that only create configuration objects without any DB I/O. The actual database work happens when .results(), .foreach(), or .execute_background() is called. Previously, spans were created at factory call time, producing meaningless traces. Add InstrumentedQueryScan proxy class that intercepts execution methods and creates spans at the correct point when real DB communication occurs. fix: address review findings for aerospike instrumentation - Handle tuple return type from get_nodes() in addition to Node objects with .name attribute, preventing silent failure on some client versions - Separate info_all from admin methods so its command string argument is not incorrectly captured as db.namespace - Wrap non-execution methods in InstrumentedQueryScan to preserve method chaining (e.g. query.select().where().results() now correctly traces) --- .../instrumentation/aerospike/__init__.py | 180 +++++++++++++-- .../tests/test_aerospike_instrumentation.py | 217 +++++++++++++++++- .../aerospike/test_aerospike_functional.py | 56 ++++- 3 files changed, 430 insertions(+), 23 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index c140d49d4f..bb300f0999 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -329,7 +329,9 @@ class InstrumentedAerospikeClient: _QUERY_APPLY_METHODS = frozenset({"query_apply"}) - _ADMIN_METHODS = frozenset({"truncate", "info_all"}) + _ADMIN_METHODS = frozenset({"truncate"}) + + _INFO_METHODS = frozenset({"info_all"}) def __init__( self, @@ -396,11 +398,7 @@ def __getattr__(self, name: str) -> Any: _extra_attrs_batch_size, ) elif name in self._QUERY_SCAN_METHODS: - wrapped = self._create_traced_wrapper( - attr, - name.upper(), - _ns_set_from_positional_args, - ) + wrapped = self._create_query_scan_factory(attr, name.upper()) elif name in self._UDF_METHODS: wrapped = self._create_traced_wrapper( attr, @@ -428,6 +426,12 @@ def __getattr__(self, name: str) -> Any: name.upper(), _ns_set_from_admin_args, ) + elif name in self._INFO_METHODS: + wrapped = self._create_traced_wrapper( + attr, + name.upper(), + _ns_set_noop, + ) if wrapped is not None: object.__setattr__(self, name, wrapped) @@ -450,13 +454,16 @@ def _update_server_info_from_nodes(self) -> None: if not nodes: return node = nodes[0] - if not hasattr(node, "name"): - return - host, port = _parse_host_port(str(node.name)) - if host: - self._server_address = host - self._server_port = port or 3000 - except (TypeError, AttributeError, IndexError): + # Handle tuple return (address, port, ...) from some client versions + 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): # If we can't get node info, keep the config-based values pass @@ -475,6 +482,28 @@ def _set_connection_attributes(self, span: Span) -> None: if self._server_port: span.set_attribute(_SERVER_PORT_ATTR, self._server_port) + def _create_query_scan_factory( + self, method: Callable, operation: str + ) -> Callable: + """Create a factory wrapper for query/scan that defers tracing. + + The factory itself (client.query / client.scan) only creates a + configuration object — no DB I/O happens until an execution method + (.results(), .foreach(), .execute_background()) is called on the + returned object. This wrapper returns an InstrumentedQueryScan + proxy so that tracing occurs at execution time, not factory time. + """ + + @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 _create_traced_wrapper( self, method: Callable, @@ -568,6 +597,123 @@ def _extra_attrs_udf_apply(self, span: Span, args: tuple) -> None: self._extra_attrs_capture_key(span, args) +class InstrumentedQueryScan: + """Instrumented wrapper for Aerospike Query/Scan objects. + + Query and Scan objects are created by client.query() / client.scan() + factory methods which perform no DB I/O. Actual database work happens + when an execution method (.results(), .foreach(), .execute_background()) + is called. This proxy defers span creation to those execution methods + while passing configuration methods straight through. + """ + + _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: + """Proxy attribute access, wrapping execution methods with tracing. + + Execution methods (results, foreach, execute_background) are wrapped + with tracing. Other callable methods are wrapped so that if they + return the inner object (for chaining), the proxy is returned instead. + """ + attr = getattr(self._inner, name) + + if callable(attr): + if name in self._EXECUTION_METHODS: + wrapped = self._create_execution_wrapper(attr) + object.__setattr__(self, name, wrapped) + return wrapped + + # Wrap non-execution methods to preserve chaining + @functools.wraps(attr) + def chaining_wrapper(*args, **kwargs): + result = attr(*args, **kwargs) + # If the method returns the inner object (self-chaining), + # return the proxy instead so instrumentation is preserved. + if result is self._inner: + return self + return result + + return chaining_wrapper + + return attr + + def _create_execution_wrapper(self, method: Callable) -> Callable: + """Create a traced wrapper for a query/scan execution method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs) -> Any: + if not is_instrumentation_enabled(): + return method(*args, **kwargs) + + span_name = _generate_span_name( + self._operation, self._namespace, self._set_name + ) + + with self._client._tracer.start_as_current_span( # noqa: SLF001 + span_name, + kind=SpanKind.CLIENT, + ) as span: + if span.is_recording(): + span.set_attribute(_DB_SYSTEM_ATTR, _DB_SYSTEM) + + if self._namespace: + span.set_attribute(_DB_NAMESPACE_ATTR, self._namespace) + if self._set_name: + span.set_attribute( + _DB_COLLECTION_NAME_ATTR, self._set_name + ) + + span.set_attribute( + _DB_OPERATION_NAME_ATTR, self._operation + ) + self._client._set_connection_attributes(span) # noqa: SLF001 + + if self._client._request_hook: # noqa: SLF001 + self._client._request_hook( # noqa: SLF001 + span, self._operation, args, kwargs + ) + + try: + result = method(*args, **kwargs) + + if self._client._response_hook: # noqa: SLF001 + self._client._response_hook( # noqa: SLF001 + span, self._operation, result + ) + + return result + + except Exception as exc: # pylint: disable=broad-exception-caught + if span.is_recording(): + _set_error_attributes(span, exc) + + if self._client._error_hook: # noqa: SLF001 + self._client._error_hook( # noqa: SLF001 + span, self._operation, exc + ) + + raise + + return wrapper + + # -- Namespace/set extraction functions -- @@ -605,6 +751,13 @@ def _ns_set_from_admin_args( return namespace, set_name +def _ns_set_noop( + args: tuple, # noqa: ARG001 +) -> tuple[str | None, str | None]: + """Return no namespace/set — used for info commands.""" + return None, None + + # -- Extra attribute setters (module-level, no self needed) -- @@ -743,5 +896,6 @@ def _set_error_attributes(span: Span, exc: Exception) -> None: __all__ = [ "AerospikeInstrumentor", "InstrumentedAerospikeClient", + "InstrumentedQueryScan", "__version__", ] diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index a29446bb66..c3cc8a362b 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -361,8 +361,8 @@ def test_proxy_passthrough(self): finally: self._uninstrument() - def test_query_operation(self): - """Test query operation creates correct span.""" + 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() @@ -371,6 +371,169 @@ def test_query_operation(self): 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) @@ -515,15 +678,40 @@ def test_query_apply_creates_correct_span(self): finally: self._uninstrument() - def test_scan_operation(self): - """Test scan operation creates correct span.""" + 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({}) - client.scan("test", "demo") + 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) @@ -553,7 +741,7 @@ def test_truncate_admin_method(self): self._uninstrument() def test_info_all_admin_method(self): - """Test info_all admin operation creates correct span.""" + """Test info_all admin operation creates correct span without namespace.""" self.mock_client.info_all.return_value = {} self._instrument() @@ -566,6 +754,8 @@ def test_info_all_admin_method(self): 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() @@ -598,6 +788,21 @@ def test_update_server_info_from_nodes(self): 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 = [] diff --git a/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py index a70fc31848..e008c79c28 100644 --- a/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py +++ b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py @@ -149,24 +149,72 @@ def test_batch_read(self): for k in keys: self._client.remove(k) - def test_query(self): - """Test query object creation creates a span.""" + 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 object creation creates a span.""" - self._client.scan("test", "demo") + """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") From 3008f99566f263bcf00a0dab621e8fefc1c9fd5b Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Sat, 31 Jan 2026 15:31:20 +0900 Subject: [PATCH 09/16] Add OpenTelemetry instrumentation for Aerospike - Implemented instrumentation for Aerospike Python client methods (CRUD, batch, query/scan, UDF, admin). - Added support for Python 3.9+ and Aerospike client >= 17.0.0. - Refactored instrumentation logic to use a generic wrapper and configuration-based method mapping for maintainability. - Included comprehensive unit tests and functional tests against a real Aerospike instance. - Verified compatibility with context7 documentation (node info parsing, query chaining). Fix pylint error in Aerospike instrumentation --- .../instrumentation/aerospike/__init__.py | 603 ++++++------------ test_udf.lua | 7 + 2 files changed, 208 insertions(+), 402 deletions(-) create mode 100644 test_udf.lua diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index bb300f0999..e8ec7aff4e 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -209,21 +209,12 @@ def error_hook(span, operation, exception): class AerospikeInstrumentor(BaseInstrumentor): - """OpenTelemetry Aerospike Instrumentor. - - This instrumentor wraps Aerospike client methods to automatically - create spans for database operations. - - Note: Aerospike Python client is a C extension, so we wrap the client - factory function (aerospike.client) to instrument each client instance. - """ + """OpenTelemetry Aerospike Instrumentor.""" def instrumentation_dependencies(self) -> Collection[str]: - """Return the dependencies required for this instrumentation.""" return _instruments def _instrument(self, **kwargs: Any) -> None: - """Instrument Aerospike client factory function.""" tracer_provider = kwargs.get("tracer_provider") tracer = trace.get_tracer( __name__, @@ -232,17 +223,15 @@ def _instrument(self, **kwargs: Any) -> None: schema_url="https://opentelemetry.io/schemas/1.28.0", ) - request_hook = kwargs.get("request_hook") - response_hook = kwargs.get("response_hook") - error_hook = kwargs.get("error_hook") - capture_key = kwargs.get("capture_key", False) - - # Wrap the client factory function wrap_function_wrapper( "aerospike", "client", _create_client_wrapper( - tracer, request_hook, response_hook, error_hook, capture_key + tracer, + kwargs.get("request_hook"), + kwargs.get("response_hook"), + kwargs.get("error_hook"), + kwargs.get("capture_key", False), ), ) @@ -260,22 +249,11 @@ def _create_client_wrapper( error_hook: Callable | None, capture_key: bool, ) -> Callable: - """Create a wrapper for aerospike.client() factory function.""" - def client_wrapper( wrapped: Callable, instance: Any, args: tuple, kwargs: dict ) -> Any: - # Create the original client client = wrapped(*args, **kwargs) - - # Extract config from args or kwargs - config = None - if args: - config = args[0] - elif "config" in kwargs: - config = kwargs["config"] - - # Wrap the client instance with our instrumented proxy + config = args[0] if args else kwargs.get("config") return InstrumentedAerospikeClient( client, tracer, @@ -289,49 +267,66 @@ def client_wrapper( return client_wrapper -class InstrumentedAerospikeClient: - """Instrumented wrapper for Aerospike Client. - - This class wraps an Aerospike client instance and adds - OpenTelemetry tracing to all database operations. - """ - - _SINGLE_RECORD_METHODS = frozenset( - { - "put", - "get", - "select", - "exists", - "remove", - "touch", - "operate", - "append", - "prepend", - "increment", - } - ) - - _BATCH_METHODS = frozenset( - { - "batch_read", - "batch_write", - "batch_operate", - "batch_remove", - "batch_apply", - } - ) - - _QUERY_SCAN_METHODS = frozenset({"query", "scan"}) - - _UDF_METHODS = frozenset({"apply"}) +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) + + if client_instance._request_hook: # noqa: SLF001 + client_instance._request_hook( # noqa: SLF001 + span, operation, args, kwargs + ) - _SCAN_APPLY_METHODS = frozenset({"scan_apply"}) + try: + result = method(*args, **kwargs) + if client_instance._response_hook: # noqa: SLF001 + 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) + if client_instance._error_hook: # noqa: SLF001 + client_instance._error_hook(span, operation, exc) # noqa: SLF001 + raise - _QUERY_APPLY_METHODS = frozenset({"query_apply"}) + return wrapper - _ADMIN_METHODS = frozenset({"truncate"}) - _INFO_METHODS = frozenset({"info_all"}) +class InstrumentedAerospikeClient: + """Instrumented wrapper for Aerospike Client.""" def __init__( self, @@ -349,104 +344,129 @@ def __init__( self._response_hook = response_hook self._error_hook = error_hook self._capture_key = capture_key - - # Store server connection info for span attributes self._server_address = None self._server_port = None - # Extract hosts from config if provided if config and isinstance(config, dict): hosts = config.get("hosts", []) if hosts: try: - first_host = hosts[0] - if isinstance(first_host, tuple) and len(first_host) >= 2: - self._server_address = str(first_host[0]) - self._server_port = int(first_host[1]) - elif ( - isinstance(first_host, tuple) and len(first_host) == 1 - ): - self._server_address = str(first_host[0]) - self._server_port = 3000 + 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 - def __getattr__(self, name: str) -> Any: - """Proxy attribute access to the wrapped client. + # Configuration for method instrumentation: + # method_name: (operation_name, extractor_func, extra_attrs_func, result_attrs_func) + self._method_config = {} + self._init_method_config() - Wrapped methods are cached via object.__setattr__ so subsequent - calls bypass __getattr__ entirely. - """ - attr = getattr(self._client, name) + def _init_method_config(self): + # Single record operations + for method in [ + "put", + "get", + "select", + "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, + ) - # If it's a method we want to instrument, wrap and cache it - if callable(attr): - wrapped = None - if name in self._SINGLE_RECORD_METHODS: - wrapped = self._create_traced_wrapper( - attr, - name.upper(), - _ns_set_from_key_arg, - self._extra_attrs_capture_key, - _set_result_attributes, - ) - elif name in self._BATCH_METHODS: - wrapped = self._create_traced_wrapper( - attr, - _get_batch_operation_name(name), - _ns_set_from_batch_arg, - _extra_attrs_batch_size, - ) - elif name in self._QUERY_SCAN_METHODS: - wrapped = self._create_query_scan_factory(attr, name.upper()) - elif name in self._UDF_METHODS: - wrapped = self._create_traced_wrapper( - attr, - name.upper().replace("_", " "), - _ns_set_from_key_arg, - self._extra_attrs_udf_apply, - ) - elif name in self._SCAN_APPLY_METHODS: - wrapped = self._create_traced_wrapper( - attr, - "SCAN APPLY", - _ns_set_from_positional_args, - _extra_attrs_scan_apply_udf, - ) - elif name in self._QUERY_APPLY_METHODS: - wrapped = self._create_traced_wrapper( - attr, - "QUERY APPLY", - _ns_set_from_positional_args, - _extra_attrs_query_apply_udf, - ) - elif name in self._ADMIN_METHODS: - wrapped = self._create_traced_wrapper( - attr, - name.upper(), - _ns_set_from_admin_args, - ) - elif name in self._INFO_METHODS: - wrapped = self._create_traced_wrapper( - attr, - name.upper(), - _ns_set_noop, - ) + # 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, + ) - if wrapped is not None: - object.__setattr__(self, name, wrapped) - return wrapped + # 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: - """Connect to the Aerospike cluster and cache server address.""" self._client.connect(*args, **kwargs) self._update_server_info_from_nodes() return self def _update_server_info_from_nodes(self) -> None: - """Try to get actual connected server info after connection.""" try: if not hasattr(self._client, "get_nodes"): return @@ -454,7 +474,6 @@ def _update_server_info_from_nodes(self) -> None: if not nodes: return node = nodes[0] - # Handle tuple return (address, port, ...) from some client versions if isinstance(node, tuple) and len(node) >= 2: self._server_address = str(node[0]) self._server_port = int(node[1]) @@ -464,19 +483,15 @@ def _update_server_info_from_nodes(self) -> None: self._server_address = host self._server_port = port or 3000 except (TypeError, AttributeError, IndexError, ValueError): - # If we can't get node info, keep the config-based values pass def close(self) -> None: - """Close the connection.""" self._client.close() def is_connected(self) -> bool: - """Check if connected.""" return self._client.is_connected() def _set_connection_attributes(self, span: Span) -> None: - """Set connection-related attributes on span.""" if self._server_address: span.set_attribute(_SERVER_ADDRESS_ATTR, self._server_address) if self._server_port: @@ -485,15 +500,6 @@ def _set_connection_attributes(self, span: Span) -> None: def _create_query_scan_factory( self, method: Callable, operation: str ) -> Callable: - """Create a factory wrapper for query/scan that defers tracing. - - The factory itself (client.query / client.scan) only creates a - configuration object — no DB I/O happens until an execution method - (.results(), .foreach(), .execute_background()) is called on the - returned object. This wrapper returns an InstrumentedQueryScan - proxy so that tracing occurs at execution time, not factory time. - """ - @functools.wraps(method) def wrapper(*args, **kwargs): query_scan_obj = method(*args, **kwargs) @@ -504,79 +510,7 @@ def wrapper(*args, **kwargs): return wrapper - def _create_traced_wrapper( - self, - method: Callable, - operation: str, - extract_ns_set: Callable[[tuple], tuple[str | None, str | None]], - set_extra_attrs: Callable[[Span, tuple], None] | None = None, - set_result_attrs: Callable[[Span, Any], None] | None = None, - ) -> Callable: - """Create a traced wrapper for an Aerospike client method. - - Args: - method: The original client method to wrap. - operation: Operation name for the span (e.g. "GET", "PUT"). - extract_ns_set: Function to extract (namespace, set_name) from args. - set_extra_attrs: Optional function to set additional span attributes. - set_result_attrs: Optional function to set attributes from the result. - """ - - @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 self._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) - self._set_connection_attributes(span) - - if set_extra_attrs: - set_extra_attrs(span, args) - - if self._request_hook: - self._request_hook(span, operation, args, kwargs) - - try: - result = method(*args, **kwargs) - - if self._response_hook: - self._response_hook(span, operation, result) - - if set_result_attrs and span.is_recording(): - set_result_attrs(span, result) - - return result - - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._error_hook: - self._error_hook(span, operation, exc) - - raise - - return wrapper - - # -- Extra attribute setters (bound methods for self access) -- - def _extra_attrs_capture_key(self, span: Span, args: tuple) -> None: - """Set key capture attribute for single record methods.""" if not self._capture_key: return key_tuple = args[0] if args else None @@ -589,7 +523,6 @@ def _extra_attrs_capture_key(self, span: Span, args: tuple) -> None: span.set_attribute(_DB_AEROSPIKE_KEY_ATTR, str(key_tuple[2])) def _extra_attrs_udf_apply(self, span: Span, args: tuple) -> None: - """Set UDF module/function and key capture for apply method.""" if len(args) > 1: span.set_attribute(_DB_AEROSPIKE_UDF_MODULE_ATTR, str(args[1])) if len(args) > 2: @@ -598,14 +531,7 @@ def _extra_attrs_udf_apply(self, span: Span, args: tuple) -> None: class InstrumentedQueryScan: - """Instrumented wrapper for Aerospike Query/Scan objects. - - Query and Scan objects are created by client.query() / client.scan() - factory methods which perform no DB I/O. Actual database work happens - when an execution method (.results(), .foreach(), .execute_background()) - is called. This proxy defers span creation to those execution methods - while passing configuration methods straight through. - """ + """Instrumented wrapper for Aerospike Query/Scan objects.""" _EXECUTION_METHODS = frozenset( {"results", "foreach", "execute_background"} @@ -626,150 +552,75 @@ def __init__( self._client = client def __getattr__(self, name: str) -> Any: - """Proxy attribute access, wrapping execution methods with tracing. - - Execution methods (results, foreach, execute_background) are wrapped - with tracing. Other callable methods are wrapped so that if they - return the inner object (for chaining), the proxy is returned instead. - """ attr = getattr(self._inner, name) - - if callable(attr): - if name in self._EXECUTION_METHODS: - wrapped = self._create_execution_wrapper(attr) - object.__setattr__(self, name, wrapped) - return wrapped - - # Wrap non-execution methods to preserve chaining - @functools.wraps(attr) - def chaining_wrapper(*args, **kwargs): - result = attr(*args, **kwargs) - # If the method returns the inner object (self-chaining), - # return the proxy instead so instrumentation is preserved. - if result is self._inner: - return self - return result - - return chaining_wrapper - - return attr - - def _create_execution_wrapper(self, method: Callable) -> Callable: - """Create a traced wrapper for a query/scan execution method.""" - - @functools.wraps(method) - def wrapper(*args, **kwargs) -> Any: - if not is_instrumentation_enabled(): - return method(*args, **kwargs) - - span_name = _generate_span_name( - self._operation, self._namespace, self._set_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 - with self._client._tracer.start_as_current_span( # noqa: SLF001 - span_name, - kind=SpanKind.CLIENT, - ) as span: - if span.is_recording(): - span.set_attribute(_DB_SYSTEM_ATTR, _DB_SYSTEM) + # 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 - if self._namespace: - span.set_attribute(_DB_NAMESPACE_ATTR, self._namespace) - if self._set_name: - span.set_attribute( - _DB_COLLECTION_NAME_ATTR, self._set_name - ) + return chaining_wrapper - span.set_attribute( - _DB_OPERATION_NAME_ATTR, self._operation - ) - self._client._set_connection_attributes(span) # noqa: SLF001 - if self._client._request_hook: # noqa: SLF001 - self._client._request_hook( # noqa: SLF001 - span, self._operation, args, kwargs - ) - - try: - result = method(*args, **kwargs) - - if self._client._response_hook: # noqa: SLF001 - self._client._response_hook( # noqa: SLF001 - span, self._operation, result - ) +# -- Helper Functions -- - return result - except Exception as exc: # pylint: disable=broad-exception-caught - if span.is_recording(): - _set_error_attributes(span, exc) - - if self._client._error_hook: # noqa: SLF001 - self._client._error_hook( # noqa: SLF001 - span, self._operation, exc - ) - - raise - - return wrapper - - -# -- Namespace/set extraction functions -- - - -def _ns_set_from_key_arg( - args: tuple, -) -> tuple[str | None, str | None]: - """Extract namespace/set from args where args[0] is a key tuple.""" - key_tuple = args[0] if args else None - return _extract_namespace_set_from_key(key_tuple) +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]: - """Extract namespace/set from args where args[0] is a batch keys list.""" - keys = args[0] if args else None - return _extract_namespace_set_from_batch(keys) +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]: - """Extract namespace/set directly from args[0] and args[1].""" 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]: - """Extract namespace/set from admin args with type checking.""" +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, # noqa: ARG001 -) -> tuple[str | None, str | None]: - """Return no namespace/set — used for info commands.""" +def _ns_set_noop(args: tuple) -> tuple[str | None, str | None]: return None, None -# -- Extra attribute setters (module-level, no self needed) -- - - def _extra_attrs_batch_size(span: Span, args: tuple) -> None: - """Set batch size attribute.""" - keys = args[0] if args else None - if keys and isinstance(keys, (list, tuple)): + 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: - """Set UDF attributes for scan_apply(ns, set, module, function, args).""" if len(args) > 2: span.set_attribute(_DB_AEROSPIKE_UDF_MODULE_ATTR, str(args[2])) if len(args) > 3: @@ -777,67 +628,23 @@ def _extra_attrs_scan_apply_udf(span: Span, args: tuple) -> None: def _extra_attrs_query_apply_udf(span: Span, args: tuple) -> None: - """Set UDF attributes for query_apply(ns, set, predicate, module, function, args).""" 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])) -# -- Helper functions -- - - def _get_batch_operation_name(method: str) -> str: - """Convert batch method name to operation name.""" method_upper = method.upper() if method_upper.startswith("BATCH_"): return f"BATCH {method_upper[6:]}" return f"BATCH {method_upper}" -def _extract_namespace_set_from_key( - key_tuple: tuple | None, -) -> tuple[str | None, str | None]: - """Extract namespace and set from a single key tuple. - - Key format: (namespace, set, key[, digest]) - """ - if not key_tuple or not isinstance(key_tuple, tuple): - return None, None - - namespace = key_tuple[0] - set_name = key_tuple[1] if len(key_tuple) > 1 else None - return namespace, set_name - - -def _extract_namespace_set_from_batch( - keys: list | tuple | None, -) -> tuple[str | None, str | None]: - """Extract namespace and set from batch keys (uses first key).""" - if not keys or not isinstance(keys, (list, tuple)): - 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 _parse_host_port(address: str) -> tuple[str | None, int | None]: - """Parse host:port string, handling IPv6 bracket notation. - - Examples: - "192.168.1.1:3000" -> ("192.168.1.1", 3000) - "[::1]:3000" -> ("::1", 3000) - "hostname" -> ("hostname", None) - "2001:db8::1" -> ("2001:db8::1", None) # IPv6 without port - """ if not address: return None, None - host, port = address, None - - # [IPv6]:port if address.startswith("["): bracket_end = address.find("]") if bracket_end >= 0: @@ -849,20 +656,17 @@ def _parse_host_port(address: str) -> tuple[str | None, int | None]: except ValueError: pass elif address.count(":") == 1: - # host:port — only split if exactly one colon to avoid IPv6 misparse 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: - """Generate span name following convention: {operation} {namespace}.{set}.""" if namespace and set_name: return f"{operation} {namespace}.{set_name}" if namespace: @@ -871,9 +675,7 @@ def _generate_span_name( def _set_result_attributes(span: Span, result: Any) -> None: - """Set attributes from operation result.""" if isinstance(result, tuple) and len(result) >= 2: - # Format: (key, meta, bins) or (key, meta) meta = result[1] if isinstance(meta, dict): if "gen" in meta: @@ -883,11 +685,8 @@ def _set_result_attributes(span: Span, result: Any) -> None: def _set_error_attributes(span: Span, exc: Exception) -> None: - """Set error attributes on span.""" span.set_status(Status(StatusCode.ERROR, str(exc))) span.set_attribute(_ERROR_TYPE_ATTR, type(exc).__name__) - - # Aerospike specific error code if hasattr(exc, "code"): span.set_attribute(_DB_RESPONSE_STATUS_CODE_ATTR, str(exc.code)) 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 From 8ecc6b06b7233f911f0d9241db3c8df0772944ea Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Sat, 31 Jan 2026 15:46:29 +0900 Subject: [PATCH 10/16] feat: update db.system to db.system.name in aerospike instrumentation --- .../src/opentelemetry/instrumentation/aerospike/__init__.py | 2 +- .../tests/test_aerospike_instrumentation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index e8ec7aff4e..aeb0ca8fd3 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -190,7 +190,7 @@ def error_hook(span, operation, exception): # Semantic convention constants _DB_SYSTEM = "aerospike" -_DB_SYSTEM_ATTR = "db.system" +_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" diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index c3cc8a362b..422441d44c 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -85,7 +85,7 @@ def test_span_properties(self): span = spans[0] self.assertEqual(span.name, "GET test.demo") self.assertEqual(span.kind, SpanKind.CLIENT) - self.assertEqual(span.attributes["db.system"], "aerospike") + 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") From 8fc1306c41d896901727320b8dfd30847f5e0444 Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Sat, 31 Jan 2026 15:49:08 +0900 Subject: [PATCH 11/16] feat: add db.user attribute support to aerospike instrumentation --- .../instrumentation/aerospike/__init__.py | 9 ++++++++ .../tests/test_aerospike_instrumentation.py | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index aeb0ca8fd3..3e9cfde2ba 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -198,6 +198,7 @@ def error_hook(span, operation, exception): _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 @@ -346,6 +347,7 @@ def __init__( 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", []) @@ -360,6 +362,11 @@ def __init__( 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 = {} @@ -496,6 +503,8 @@ def _set_connection_attributes(self, span: Span) -> None: 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 diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index 422441d44c..30a497e54d 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -876,3 +876,26 @@ def test_wrapper_caching(self): 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() From 6b8facabbaa64290a8d782601be9202058936afd Mon Sep 17 00:00:00 2001 From: kimsoungryoul Date: Sat, 31 Jan 2026 15:52:12 +0900 Subject: [PATCH 12/16] feat: add db.aerospike.bins attribute to aerospike instrumentation --- .../instrumentation/aerospike/__init__.py | 21 +++++++++- .../tests/test_aerospike_instrumentation.py | 39 +++++++++++++++++++ .../aerospike/test_aerospike_functional.py | 2 +- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 3e9cfde2ba..882f15b9e8 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -207,6 +207,7 @@ def error_hook(span, operation, exception): _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): @@ -375,9 +376,7 @@ def __init__( def _init_method_config(self): # Single record operations for method in [ - "put", "get", - "select", "exists", "remove", "touch", @@ -393,6 +392,15 @@ def _init_method_config(self): _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", @@ -531,6 +539,15 @@ def _extra_attrs_capture_key(self, span: Span, args: tuple) -> 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])) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index 30a497e54d..1eb25a2d7a 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -899,3 +899,42 @@ def test_user_attribute_from_config(self): self.assertEqual(span.attributes.get("db.user"), "test_user") 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/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py index e008c79c28..56959046be 100644 --- a/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py +++ b/tests/opentelemetry-docker-tests/tests/aerospike/test_aerospike_functional.py @@ -65,7 +65,7 @@ def _assert_base_attrs( ): """Assert common span attributes.""" self.assertEqual(span.kind, SpanKind.CLIENT) - self.assertEqual(span.attributes["db.system"], "aerospike") + 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) From 71889bfc58dbeaa3168b185f3078f284a5e05955 Mon Sep 17 00:00:00 2001 From: KimSoungRyoul Date: Sat, 31 Jan 2026 16:50:17 +0900 Subject: [PATCH 13/16] docs: add changelog entry for aerospike instrumentation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5215be9f..2f21c94698 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 From 3dc1084d3a2c124860b7ba501e382f9af18834ba Mon Sep 17 00:00:00 2001 From: KimSoungRyoul Date: Sat, 31 Jan 2026 16:58:07 +0900 Subject: [PATCH 14/16] docs: document captured attributes in aerospike instrumentation readme --- .../README.rst | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/README.rst b/instrumentation/opentelemetry-instrumentation-aerospike/README.rst index a275b7d95e..af510681ff 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/README.rst +++ b/instrumentation/opentelemetry-instrumentation-aerospike/README.rst @@ -63,6 +63,31 @@ Aerospike Client Version Compatibility 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 ---------- From 0df25e7bc075739d10999f4b32591f2383f8f403 Mon Sep 17 00:00:00 2001 From: KimSoungRyoul Date: Tue, 10 Feb 2026 23:07:30 +0900 Subject: [PATCH 15/16] fix: add exception handling for hooks in aerospike instrumentation Wrap request_hook, response_hook, and error_hook calls in try-except blocks so that user-provided hook exceptions do not break the instrumented operation. Follows the same pattern used in pymongo instrumentation. Adds 3 corresponding unit tests. --- .../instrumentation/aerospike/__init__.py | 26 +++++-- .../tests/test_aerospike_instrumentation.py | 75 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index 882f15b9e8..f6ed3e6b9b 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -173,6 +173,7 @@ def error_hook(span, operation, exception): from __future__ import annotations import functools +import logging from collections.abc import Callable, Collection from typing import Any @@ -188,6 +189,8 @@ def error_hook(span, operation, exception): ) 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" @@ -304,16 +307,22 @@ def wrapper(*args, **kwargs) -> Any: set_extra_attrs(span, args) if client_instance._request_hook: # noqa: SLF001 - client_instance._request_hook( # noqa: SLF001 - span, operation, args, kwargs - ) + try: + client_instance._request_hook( # noqa: SLF001 + span, operation, args, kwargs + ) + except Exception: # noqa: BLE001 + _logger.exception("Error executing request_hook") try: result = method(*args, **kwargs) if client_instance._response_hook: # noqa: SLF001 - client_instance._response_hook( # noqa: SLF001 - span, operation, result - ) + try: + client_instance._response_hook( # noqa: SLF001 + span, operation, result + ) + except Exception: # noqa: BLE001 + _logger.exception("Error executing response_hook") if set_result_attrs and span.is_recording(): set_result_attrs(span, result) return result @@ -321,7 +330,10 @@ def wrapper(*args, **kwargs) -> Any: if span.is_recording(): _set_error_attributes(span, exc) if client_instance._error_hook: # noqa: SLF001 - client_instance._error_hook(span, operation, exc) # noqa: SLF001 + try: + client_instance._error_hook(span, operation, exc) # noqa: SLF001 + except Exception: # noqa: BLE001 + _logger.exception("Error executing error_hook") raise return wrapper diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index 1eb25a2d7a..50cfffe232 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -900,6 +900,81 @@ def test_user_attribute_from_config(self): 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 From a75032d0dfffec309a392573bb23fe5e031fdf41 Mon Sep 17 00:00:00 2001 From: KimSoungRyoul Date: Tue, 10 Feb 2026 23:26:46 +0900 Subject: [PATCH 16/16] fix: extract _safe_call_hook to resolve pylint lint errors - Extract hook calling logic into _safe_call_hook helper to reduce branches in _traced_method (R0912: too-many-branches) - Consolidate broad-exception-caught handling into one place (W0718) - Add too-many-lines pylint disable for test module (C0302) --- .../instrumentation/aerospike/__init__.py | 47 +++++++++++-------- .../tests/test_aerospike_instrumentation.py | 2 +- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py index f6ed3e6b9b..dec9954690 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/src/opentelemetry/instrumentation/aerospike/__init__.py @@ -272,6 +272,15 @@ def client_wrapper( 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, @@ -306,34 +315,34 @@ def wrapper(*args, **kwargs) -> Any: if set_extra_attrs: set_extra_attrs(span, args) - if client_instance._request_hook: # noqa: SLF001 - try: - client_instance._request_hook( # noqa: SLF001 - span, operation, args, kwargs - ) - except Exception: # noqa: BLE001 - _logger.exception("Error executing request_hook") + _safe_call_hook( + client_instance._request_hook, # noqa: SLF001 + span, + operation, + args, + kwargs, + ) try: result = method(*args, **kwargs) - if client_instance._response_hook: # noqa: SLF001 - try: - client_instance._response_hook( # noqa: SLF001 - span, operation, result - ) - except Exception: # noqa: BLE001 - _logger.exception("Error executing response_hook") + _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) - if client_instance._error_hook: # noqa: SLF001 - try: - client_instance._error_hook(span, operation, exc) # noqa: SLF001 - except Exception: # noqa: BLE001 - _logger.exception("Error executing error_hook") + _safe_call_hook( + client_instance._error_hook, # noqa: SLF001 + span, + operation, + exc, + ) raise return wrapper diff --git a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py index 50cfffe232..cd13872ce6 100644 --- a/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-aerospike/tests/test_aerospike_instrumentation.py @@ -12,7 +12,7 @@ # 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 +# 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."""