diff --git a/.changelog/5337.added b/.changelog/5337.added new file mode 100644 index 0000000000..64b5a11e72 --- /dev/null +++ b/.changelog/5337.added @@ -0,0 +1 @@ +`opentelemetry-process-context`: implement process context publishing (OTEP-4719) diff --git a/.codespellrc b/.codespellrc index 788c648bc5..981b4e224f 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,4 +1,4 @@ [codespell] # skipping auto generated folders -skip = ./.tox,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,.git,./opentelemetry-semantic-conventions,*-requirements*.txt -ignore-words-list = ans,ue,ot,hist,ro,astroid +skip = ./.tox,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,.git,./opentelemetry-semantic-conventions,*-requirements*.txt,./opentelemetry-process-context/rust/target +ignore-words-list = ans,ue,ot,hist,ro,astroid,crate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68cedf5584..2ba8bc7b16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: uses: ./.github/workflows/misc.yml lint: uses: ./.github/workflows/lint.yml + rust: + uses: ./.github/workflows/rust.yml tests: uses: ./.github/workflows/test.yml contrib: @@ -39,6 +41,7 @@ jobs: needs: - misc - lint + - rust - tests - contrib runs-on: ubuntu-latest diff --git a/.github/workflows/generate_workflows.py b/.github/workflows/generate_workflows.py index 5b53c211eb..549f3b1d2e 100644 --- a/.github/workflows/generate_workflows.py +++ b/.github/workflows/generate_workflows.py @@ -1,9 +1,11 @@ from collections import defaultdict from pathlib import Path from re import compile as re_compile +from re import fullmatch from jinja2 import Environment, FileSystemLoader from tox.config.cli.parse import get_options +from tox.config.main import Config from tox.config.sets import CoreConfigSet from tox.config.source.tox_ini import ToxIni from tox.session.state import State @@ -18,7 +20,7 @@ ) -def get_tox_envs(tox_ini_path: Path) -> list: +def get_core_config_set(tox_ini_path: Path) -> tuple[Config, CoreConfigSet]: tox_ini = ToxIni(tox_ini_path) conf = State(get_options(), []).conf @@ -40,11 +42,39 @@ def get_tox_envs(tox_ini_path: Path) -> list: ) ) - return core_config_set.load("env_list") + return conf, core_config_set + + +def get_tox_envs(tox_ini_path: Path) -> list: + return get_core_config_set(tox_ini_path)[1].load("env_list") + + +def get_env_platforms(tox_ini_path: Path) -> dict[str, str]: + conf, core_config_set = get_core_config_set(tox_ini_path) + + platforms = {} + for env_name in core_config_set.load("env_list"): + env_config_set = conf.get_env(env_name) + env_config_set.add_config( + keys=["platform"], + of_type=str, + default="", + desc="platform constraint regex", + ) + platforms[env_name] = env_config_set.load("platform") + + return platforms -def get_test_job_datas(tox_envs: list, operating_systems: list) -> list: + +def get_test_job_datas( + tox_envs: list, operating_systems: list, env_platforms: dict[str, str] +) -> list: os_alias = {"ubuntu-latest": "Ubuntu", "windows-latest": "Windows"} + os_sys_platform = { + "ubuntu-latest": "linux", + "windows-latest": "win32", + } python_version_alias = { "pypy3": "pypy-3.10", @@ -59,12 +89,19 @@ def get_test_job_datas(tox_envs: list, operating_systems: list) -> list: test_job_datas = [] for operating_system in operating_systems: + sys_platform = os_sys_platform[operating_system] + for tox_env in tox_envs: tox_test_env_match = _tox_test_env_regex.match(tox_env) if tox_test_env_match is None: continue + if (platform := env_platforms.get(tox_env, "")) and not fullmatch( + platform, sys_platform + ): + continue + groups = tox_test_env_match.groupdict() aliased_python_version = python_version_alias[ @@ -156,7 +193,11 @@ def generate_test_workflow( tox_ini_path: Path, workflow_directory_path: Path, operating_systems ) -> None: _generate_workflow( - get_test_job_datas(get_tox_envs(tox_ini_path), operating_systems), + get_test_job_datas( + get_tox_envs(tox_ini_path), + operating_systems, + get_env_platforms(tox_ini_path), + ), "test", workflow_directory_path, ) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 27033640dc..b24172de37 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -423,6 +423,25 @@ jobs: - name: Run tests run: tox -e lint-opentelemetry-exporter-zipkin-json + lint-opentelemetry-process-context: + name: opentelemetry-process-context + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-opentelemetry-process-context + lint-opentelemetry-propagator-b3: name: opentelemetry-propagator-b3 runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3179fc3486..6a2b5f16f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,72 @@ permissions: contents: read jobs: + build-process-context-wheels: + name: process-context wheels (${{ matrix.platform.target }} ${{ matrix.platform.manylinux }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + # manylinux + - { target: x86_64, manylinux: auto } + - { target: x86, manylinux: auto } + - { target: aarch64, manylinux: auto } + - { target: armv7, manylinux: auto } + - { target: ppc64le, manylinux: auto } + - { target: s390x, manylinux: auto } + # musllinux + - { target: x86_64, manylinux: musllinux_1_2 } + - { target: x86, manylinux: musllinux_1_2 } + - { target: aarch64, manylinux: musllinux_1_2 } + - { target: armv7, manylinux: musllinux_1_2 } + steps: + - run: | + if [[ $GITHUB_REF_NAME != release/* ]]; then + echo this workflow should only be run against release branches + exit 1 + fi + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Build wheels + uses: PyO3/maturin-action@3e2bdf6ba6453a61e649744019b8a2d906c7eb38 # v1.51.0 + with: + target: ${{ matrix.platform.target }} + manylinux: ${{ matrix.platform.manylinux }} + args: --release --out dist --manifest-path opentelemetry-process-context/rust/Cargo.toml + sccache: 'true' + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: process-context-wheels-${{ matrix.platform.target }}-${{ matrix.platform.manylinux }} + path: dist + + build-process-context-sdist: + name: process-context sdist + runs-on: ubuntu-latest + steps: + - run: | + if [[ $GITHUB_REF_NAME != release/* ]]; then + echo this workflow should only be run against release branches + exit 1 + fi + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Build sdist + uses: PyO3/maturin-action@3e2bdf6ba6453a61e649744019b8a2d906c7eb38 # v1.51.0 + with: + command: sdist + args: --out dist --manifest-path opentelemetry-process-context/rust/Cargo.toml + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: process-context-sdist + path: dist + release: + needs: [build-process-context-wheels, build-process-context-sdist] permissions: contents: write # required for creating GitHub releases runs-on: ubuntu-latest @@ -17,7 +82,7 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install toml run: pip install toml @@ -64,21 +129,31 @@ jobs: # check out main branch to verify there won't be problems with merging the change log # at the end of this workflow - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main # back to the release branch - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # next few steps publish to pypi - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.10' - name: Build wheels run: ./scripts/build.sh + # Native wheels + sdist for opentelemetry-process-context, built by the + # build-process-context-* jobs above are merged into dist/ so the twine + # uploads below publish them alongside the pure Python packages. + - name: Download process-context wheels and sdist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: process-context-* + merge-multiple: true + path: dist + - name: Install twine run: | pip install twine diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000000..43a6451835 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,43 @@ +# This file is not auto-generated. See .github/workflows/templates/ for generated CI files. + +name: Rust + +on: + workflow_call: + +permissions: + contents: read + +jobs: + fmt-process-context: + name: cargo fmt (opentelemetry-process-context) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Update Rust toolchain + run: rustup update stable && rustup default stable + - name: cargo fmt + run: cargo fmt --check --manifest-path opentelemetry-process-context/rust/Cargo.toml + + clippy-process-context: + name: cargo clippy (opentelemetry-process-context) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Update Rust toolchain + run: rustup update stable && rustup default stable + - name: cargo clippy + run: cargo clippy --manifest-path opentelemetry-process-context/rust/Cargo.toml -- -D warnings + + test-process-context: + name: cargo test (opentelemetry-process-context) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Update Rust toolchain + run: rustup update stable && rustup default stable + - name: cargo test + run: cargo test --manifest-path opentelemetry-process-context/rust/Cargo.toml diff --git a/.github/workflows/templates/ci.yml.j2 b/.github/workflows/templates/ci.yml.j2 index ac67f5b2f9..a9d0039c68 100644 --- a/.github/workflows/templates/ci.yml.j2 +++ b/.github/workflows/templates/ci.yml.j2 @@ -22,6 +22,8 @@ jobs: uses: ./.github/workflows/misc.yml lint: uses: ./.github/workflows/lint.yml + rust: + uses: ./.github/workflows/rust.yml tests: uses: ./.github/workflows/test.yml contrib: @@ -39,6 +41,7 @@ jobs: needs: - misc - lint + - rust - tests - contrib runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d562b9d874..4219292b92 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3086,6 +3086,120 @@ jobs: - name: Run tests run: tox -e pypy3-test-opentelemetry-exporter-zipkin-json -- -ra + py310-test-opentelemetry-process-context_ubuntu-latest: + name: opentelemetry-process-context 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-opentelemetry-process-context -- -ra + + py311-test-opentelemetry-process-context_ubuntu-latest: + name: opentelemetry-process-context 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-opentelemetry-process-context -- -ra + + py312-test-opentelemetry-process-context_ubuntu-latest: + name: opentelemetry-process-context 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-opentelemetry-process-context -- -ra + + py313-test-opentelemetry-process-context_ubuntu-latest: + name: opentelemetry-process-context 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-opentelemetry-process-context -- -ra + + py314-test-opentelemetry-process-context_ubuntu-latest: + name: opentelemetry-process-context 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-opentelemetry-process-context -- -ra + + py314t-test-opentelemetry-process-context_ubuntu-latest: + name: opentelemetry-process-context 3.14t Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14t + uses: actions/setup-python@v5 + with: + python-version: "3.14t" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314t-test-opentelemetry-process-context -- -ra + py310-test-opentelemetry-propagator-b3_ubuntu-latest: name: opentelemetry-propagator-b3 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/eachdist.ini b/eachdist.ini index 60f1661def..f564b1b5dd 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -39,6 +39,7 @@ packages= opentelemetry-exporter-otlp-json-file opentelemetry-distro opentelemetry-proto-json + opentelemetry-process-context opentelemetry-semantic-conventions opentelemetry-test-utils tests diff --git a/opentelemetry-process-context/LICENSE b/opentelemetry-process-context/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/opentelemetry-process-context/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/opentelemetry-process-context/README.rst b/opentelemetry-process-context/README.rst new file mode 100644 index 0000000000..956aa0bd88 --- /dev/null +++ b/opentelemetry-process-context/README.rst @@ -0,0 +1,70 @@ +OpenTelemetry Process Context +============================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-process-context.svg + :target: https://pypi.org/project/opentelemetry-process-context/ + +This library implements the `OpenTelemetry Process Context`_ specification (OTEP 4719). +It serializes the process's OTel ``Resource`` attributes into a protobuf payload and +publishes them into a named memory mapped region called ``OTEL_CTX``, making the +process discoverable by out-of-process readers such as the +`OpenTelemetry eBPF Profiler`_ via ``/proc//maps`` and ``/proc//mem`` on +Linux without any in-process integration or network communication. + +.. _OpenTelemetry Process Context: https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/profiles/4719-process-ctx.md +.. _OpenTelemetry eBPF Profiler: https://github.com/open-telemetry/opentelemetry-ebpf-profiler + +Installation +------------ + +:: + + pip install opentelemetry-process-context + +Platform Requirements +--------------------- + +This package targets Linux. On Linux the mapping is created via ``memfd`` so it +appears as a named entry (``/memfd:OTEL_CTX``) in ``/proc//maps``, and +``MADV_DONTFORK`` prevents child processes from inheriting it. A fallback to an +anonymous ``mmap`` is provided for other Unix-like systems, non-Unix and 32-bit +platforms raise ``RuntimeError``. + +Usage +----- + +Call ``publish_context`` with the same ``Resource`` used to configure your +OpenTelemetry SDK provider. The mapping stays live for the lifetime of the process. +Call ``unpublish_context`` on shutdown to remove it explicitly. + +.. code-block:: python + + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.process_context import publish_context, unpublish_context + + resource = Resource(attributes={ + "service.name": "my-service", + "service.version": "1.0.0", + }) + + provider = TracerProvider(resource=resource) + # register provider with trace / metrics / logs APIs ... + + publish_context(resource) + + # Optionally publish supplementary attributes that are not part of the + # standard Resource. + publish_context(resource, {"deployment.environment": "prod"}) + + # On shutdown (optional): + unpublish_context() + +References +---------- + +* `OpenTelemetry Process Context specification (OTEP 4719) `_ +* `OpenTelemetry eBPF Profiler `_ +* `OpenTelemetry `_ diff --git a/opentelemetry-process-context/pyproject.toml b/opentelemetry-process-context/pyproject.toml new file mode 100644 index 0000000000..1ee2a47ca5 --- /dev/null +++ b/opentelemetry-process-context/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "opentelemetry-process-context" +version = "0.65b0.dev" +description = "OpenTelemetry Process Context" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: OpenTelemetry", + "Framework :: OpenTelemetry :: Profiles", + "Intended Audience :: Developers", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.15", + "opentelemetry-sdk ~= 1.15", +] + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/opentelemetry-process-context" +Repository = "https://github.com/open-telemetry/opentelemetry-python" + +[tool.maturin] +python-source = "src" +module-name = "opentelemetry.process_context._rs" +manifest-path = "rust/Cargo.toml" + +[tool.uv] +cache-keys = [ + { file = "rust/Cargo.toml" }, + { file = "rust/Cargo.lock" }, + { file = "rust/src/**" }, +] diff --git a/opentelemetry-process-context/rust/Cargo.lock b/opentelemetry-process-context/rust/Cargo.lock new file mode 100644 index 0000000000..f919f6cda7 --- /dev/null +++ b/opentelemetry-process-context/rust/Cargo.lock @@ -0,0 +1,353 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opentelemetry-process-context" +version = "0.1.0" +dependencies = [ + "nix", + "prost", + "pyo3", + "serial_test", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/opentelemetry-process-context/rust/Cargo.toml b/opentelemetry-process-context/rust/Cargo.toml new file mode 100644 index 0000000000..98890b68ff --- /dev/null +++ b/opentelemetry-process-context/rust/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "opentelemetry-process-context" +version = "0.1.0" +edition = "2024" + +[lib] +name = "opentelemetry_process_context" +crate-type = ["cdylib"] + +[dependencies] +prost = "0.13" + +[dependencies.pyo3] +version = "0.28.2" +features = ["abi3-py39"] + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", features = ["mman", "fs", "time"] } + +[dev-dependencies] +serial_test = "3" diff --git a/opentelemetry-process-context/rust/src/context.rs b/opentelemetry-process-context/rust/src/context.rs new file mode 100644 index 0000000000..84d87f6ec5 --- /dev/null +++ b/opentelemetry-process-context/rust/src/context.rs @@ -0,0 +1,416 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Publication of the process context to a memory region that out of process +//! readers (e.g. the OpenTelemetry eBPF Profiler) can discover and read. +//! +//! The layout follows the OpenTelemetry "Process Context" specification: a fixed +//! 32-byte header mapping named `OTEL_CTX` and backed by a `memfd` on Linux +//! (visible in `/proc//maps`), with the payload living out of band in a +//! heap allocated buffer. + +use std::ffi::c_void; +use std::num::NonZeroUsize; +use std::ptr::NonNull; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use nix::sys::mman::{MapFlags, ProtFlags, mmap_anonymous}; +use nix::time::{ClockId, clock_gettime}; +use pyo3::PyErr; +use pyo3::exceptions::{PyOSError, PyRuntimeError}; + +#[cfg(target_os = "linux")] +use nix::sys::memfd::{MFdFlags, memfd_create}; +#[cfg(target_os = "linux")] +use nix::sys::mman::mmap; + +#[cfg(target_os = "linux")] +const PR_SET_VMA: nix::libc::c_int = 0x53564d41; +#[cfg(target_os = "linux")] +const PR_SET_VMA_ANON_NAME: nix::libc::c_ulong = 0; + +/// 8 byte signature stamped at the start of the header. +const SIGNATURE: [u8; 8] = *b"OTEL_CTX"; +/// Format version. `2` is the first stable version (`1` is for development). +const VERSION: u32 = 2; +/// Size of the header mapping in bytes. The payload lives on the heap. +const HEADER_SIZE: usize = std::mem::size_of::
(); + +/// The process context header. +#[repr(C)] +struct Header { + signature: [u8; 8], + version: u32, + payload_size: u32, + monotonic_published_at_ns: AtomicU64, + payload: u64, +} + +/// A published mapping. +struct Region { + ptr: NonNull, + /// PID of the process that created this region. Used to detect a region + /// inherited across a `fork`, whose `ptr` must not be dereferenced. + owner_pid: u32, + #[allow(unused)] + payload: Vec, +} + +// SAFETY: all access goes through `MAPPING` (a `Mutex`), which serializes +// reads and writes. The pointer lives for the process lifetime. +unsafe impl Send for Region {} + +/// The single active process context for this process, if any. +static MAPPING: Mutex> = Mutex::new(None); + +/// Lock [`MAPPING`], recovering the guard if a previous holder panicked. The +/// protected data is a plain `Option` with no broken invariants on +/// panic, so ignoring poisoning is safe and keeps the API usable. +fn lock_mapping() -> std::sync::MutexGuard<'static, Option> { + MAPPING + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Discard a `Region` inherited from a parent process across a `fork`. On Linux +/// the header mapping was never inherited (`MADV_DONTFORK`), so `region.ptr` +/// points to unmapped memory and is simply abandoned. Elsewhere the child +/// inherited a private copy, so unmap it to avoid leaking. The inherited +/// `payload` Vec is dropped in both cases. +fn discard_inherited(region: Region) { + #[cfg(not(target_os = "linux"))] + // SAFETY: on non-Linux the child holds a private copy of the mapping at + // `region.ptr` spanning `HEADER_SIZE` bytes. + unsafe { + let _ = nix::sys::mman::munmap(region.ptr, HEADER_SIZE); + } + // Explicit drop for readability + drop(region); +} + +#[derive(Debug)] +pub enum PublishError { + /// The backing memory region could not be allocated. + Alloc, + /// `madvise(MADV_DONTFORK)` failed. + Madvise, + /// The monotonic clock could not be read for the publish timestamp. + Clock, + /// `munmap` of the header mapping failed during unpublish. + Munmap, + /// `unpublish()` was called before any context was published. + NotPublished, +} + +impl From for PyErr { + fn from(err: PublishError) -> Self { + match err { + PublishError::Alloc => { + PyOSError::new_err("failed to allocate the process context mapping") + } + PublishError::Madvise => { + PyOSError::new_err("madvise(MADV_DONTFORK) failed for the process context mapping") + } + PublishError::Clock => { + PyOSError::new_err("failed to read the monotonic clock for the process context") + } + PublishError::Munmap => { + PyOSError::new_err("munmap of the process context mapping failed") + } + PublishError::NotPublished => { + PyRuntimeError::new_err("no process context has been published yet") + } + } + } +} + +/// Allocate the mapping and write the initial header. Called with the mutex held +/// and `guard` confirmed to be `None`. +fn publish_new( + guard: &mut Option, + payload: Vec, + owner_pid: u32, +) -> Result<(), PublishError> { + let timestamp = get_boottime_ns()?; + + let ptr = alloc_region()?; + + // `ptr` is now a live mapping we own but have not yet stored in `guard`. + // If advising fails, unmap it before propagating so it does not leak. + if let Err(err) = advise_dontfork(ptr, HEADER_SIZE) { + // SAFETY: `ptr`/`HEADER_SIZE` describe the mapping we just created. + unsafe { + let _ = nix::sys::mman::munmap(ptr, HEADER_SIZE); + } + return Err(err); + } + + // SAFETY: `ptr` points to a freshly mapped, zero initialized, page aligned + // region of exactly `HEADER_SIZE` bytes. The payload lives in `payload` on + // the heap and the header's `payload` field stores a pointer into it. + unsafe { + let header = ptr.as_ptr().cast::
(); + + std::ptr::addr_of_mut!((*header).signature).write(SIGNATURE); + std::ptr::addr_of_mut!((*header).version).write(VERSION); + std::ptr::addr_of_mut!((*header).payload_size).write(payload.len() as u32); + std::ptr::addr_of_mut!((*header).payload).write(payload.as_ptr() as u64); + + // Write the timestamp last with release ordering, ensuring that + // all writes above are not reordered after the timestamp store. + let published_at = &*std::ptr::addr_of!((*header).monotonic_published_at_ns); + published_at.store(timestamp, Ordering::Release); + } + + // Best effort naming so readers can find the mapping even without a memfd + // path, failures are ignored per the spec. + name_mapping(ptr, HEADER_SIZE); + + *guard = Some(Region { + ptr, + owner_pid, + payload, + }); + Ok(()) +} + +/// Rewrite the payload of an existing mapping in place. Called with the mutex +/// held and `region` confirmed to be live. +/// +/// Follows the spec's Updating Protocol: zeros the timestamp to signal readers +/// that an update is in progress, rewrites the payload fields, then publishes +/// the new timestamp. The old payload buffer is dropped after the new timestamp +/// is live. +fn publish_existing(region: &mut Region, payload: Vec) -> Result<(), PublishError> { + let timestamp = get_boottime_ns()?; + + // SAFETY: `region.ptr` points to the live header mapping with exactly + // `HEADER_SIZE` bytes, writable for the process lifetime. + unsafe { + let header = region.ptr.as_ptr().cast::
(); + let published_at = &*std::ptr::addr_of!((*header).monotonic_published_at_ns); + + // Zero timestamp signals the "update in progress" state. + published_at.store(0, Ordering::Relaxed); + // An `Ordering::Release` fence is needed here to ensure that the + // preceding "update in progress" write above is not reordered with + // any of the proceeding writes that update the region. + std::sync::atomic::fence(Ordering::Release); + + // Rewrite payload fields between the two Release stores. + std::ptr::addr_of_mut!((*header).payload_size).write(payload.len() as u32); + std::ptr::addr_of_mut!((*header).payload).write(payload.as_ptr() as u64); + + // Publish new timestamp with Release ensuring the new payload fields + // are visible to readers that observe the "update complete" signal. + published_at.store(timestamp, Ordering::Release); + } + + // Rename the mapping unconditionally (failures ignored per spec). + name_mapping(region.ptr, HEADER_SIZE); + + // Drop the old payload only after the new timestamp is live. + region.payload = payload; + Ok(()) +} + +/// Publish or update the process context. +/// +/// Creates the named memory mapping on the first call. On subsequent calls the +/// existing mapping is updated in-place using the spec's Updating Protocol +pub fn publish(payload: Vec) -> Result<(), PublishError> { + let current_pid = std::process::id(); + let mut guard = lock_mapping(); + + // A region whose owner PID differs from ours was inherited across a fork. + // Drop it so the stale parent pointer is never dereferenced. + if guard.as_ref().is_some_and(|r| r.owner_pid != current_pid) { + discard_inherited(guard.take().unwrap()); + } + + if let Some(region) = guard.as_mut() { + publish_existing(region, payload) + } else { + publish_new(&mut guard, payload, current_pid) + } +} + +/// Remove the published process context. +/// +/// Zeros the timestamp (Release) so readers still observing the mapping see an +/// invalid state, then `munmap`s the header and drops the payload buffer. +/// After a successful call, `publish()` may be called again. +pub fn unpublish() -> Result<(), PublishError> { + let current_pid = std::process::id(); + let mut guard = lock_mapping(); + + // A region inherited across a fork was never published by this process. + // Discard it and report that nothing was published here. + if guard.as_ref().is_some_and(|r| r.owner_pid != current_pid) { + discard_inherited(guard.take().unwrap()); + return Err(PublishError::NotPublished); + } + + let region = guard.take().ok_or(PublishError::NotPublished)?; + + // Zero the timestamp and remove the mapping. + unsafe { + let header = region.ptr.as_ptr().cast::
(); + let published_at = &*std::ptr::addr_of!((*header).monotonic_published_at_ns); + // No ordering constraint is required because regardless of ordering, + // a reader can observe a valid timestamp before the store and subsequently + // attempt to read from the header or payload pointer after it has already + // deallocated/unmapped. + published_at.store(0, Ordering::Relaxed); + } + + unsafe { nix::sys::mman::munmap(region.ptr, HEADER_SIZE) }.map_err(|_| PublishError::Munmap) +} + +/// Allocate the 32-byte header mapping: a `memfd`-backed mapping on Linux (so +/// it shows up in `/proc//maps`), falling back to an anonymous mapping. +fn alloc_region() -> Result, PublishError> { + let len = NonZeroUsize::new(HEADER_SIZE).unwrap(); + + if let Some(ptr) = try_memfd_mapping(len) { + return Ok(ptr); + } + + // SAFETY: a fresh anonymous mapping with a valid, non-zero length. + unsafe { + mmap_anonymous( + None, + len, + ProtFlags::PROT_READ | ProtFlags::PROT_WRITE, + MapFlags::MAP_PRIVATE | MapFlags::MAP_ANONYMOUS, + ) + } + .map_err(|_| PublishError::Alloc) +} + +/// Read the publish timestamp. Uses `CLOCK_BOOTTIME` on Linux (as the spec +/// requires) and `CLOCK_MONOTONIC` elsewhere. The value is forced non-zero, as a +/// zero timestamp is reserved to mean "being mutated, not ready". +fn get_boottime_ns() -> Result { + #[cfg(target_os = "linux")] + let clock = ClockId::CLOCK_BOOTTIME; + #[cfg(not(target_os = "linux"))] + let clock = ClockId::CLOCK_MONOTONIC; + + let ts = clock_gettime(clock).map_err(|_| PublishError::Clock)?; + let ns = (ts.tv_sec() as u64) + .saturating_mul(1_000_000_000) + .saturating_add(ts.tv_nsec() as u64); + Ok(ns.max(1)) +} + +/// Prevent child processes from inheriting (stale) context memory. +#[cfg(target_os = "linux")] +fn advise_dontfork(ptr: NonNull, len: usize) -> Result<(), PublishError> { + // SAFETY: `ptr`/`len` describe the mapping we just created. + unsafe { nix::sys::mman::madvise(ptr, len, nix::sys::mman::MmapAdvise::MADV_DONTFORK) } + .map_err(|_| PublishError::Madvise) +} + +#[cfg(not(target_os = "linux"))] +fn advise_dontfork(_ptr: NonNull, _len: usize) -> Result<(), PublishError> { + Ok(()) +} + +/// Create a `memfd`, size it, and map it `MAP_PRIVATE`. Returns `None` if any +/// step fails so the caller can fall back to an anonymous mapping. +#[cfg(target_os = "linux")] +fn try_memfd_mapping(len: NonZeroUsize) -> Option> { + let base = MFdFlags::MFD_CLOEXEC | MFdFlags::MFD_ALLOW_SEALING; + // `MFD_NOEXEC_SEAL` is a newer flag not exposed by `nix`, request it but + // fall back without it on kernels/libc that lack it. + let noexec_seal = MFdFlags::from_bits_retain(nix::libc::MFD_NOEXEC_SEAL as _); + let fd = memfd_create(c"OTEL_CTX", base | noexec_seal) + .or_else(|_| memfd_create(c"OTEL_CTX", base)) + .ok()?; + + nix::unistd::ftruncate(&fd, len.get() as nix::libc::off_t).ok()?; + + // SAFETY: `fd` is a valid, sized memfd and `len` is non-zero. + let ptr = unsafe { + mmap( + None, + len, + ProtFlags::PROT_READ | ProtFlags::PROT_WRITE, + MapFlags::MAP_PRIVATE, + &fd, + 0, + ) + } + .ok()?; + Some(ptr) +} + +#[cfg(not(target_os = "linux"))] +fn try_memfd_mapping(_len: NonZeroUsize) -> Option> { + None +} + +/// Name the mapping `OTEL_CTX` via `prctl(PR_SET_VMA_ANON_NAME)`. +#[cfg(target_os = "linux")] +fn name_mapping(ptr: NonNull, len: usize) { + const NAME: &core::ffi::CStr = c"OTEL_CTX"; + unsafe { + let _ = nix::libc::prctl( + PR_SET_VMA, + PR_SET_VMA_ANON_NAME, + ptr.as_ptr() as nix::libc::c_ulong, + len as nix::libc::c_ulong, + NAME.as_ptr() as nix::libc::c_ulong, + ); + } +} + +#[cfg(not(target_os = "linux"))] +fn name_mapping(_ptr: NonNull, _len: usize) {} + +#[cfg(test)] +mod tests { + use super::{PublishError, publish, unpublish}; + use serial_test::serial; + + #[test] + fn get_boottime_ns_is_nonzero() { + let ns = super::get_boottime_ns().unwrap(); + assert!(ns >= 1); + } + + #[test] + #[serial] + fn unpublish_without_publish_returns_not_published() { + let _ = unpublish(); + assert!(matches!(unpublish(), Err(PublishError::NotPublished))); + } + + #[test] + #[serial] + fn publish_then_unpublish_succeeds() { + let _ = unpublish(); + publish(b"test".to_vec()).unwrap(); + unpublish().unwrap(); + } + + #[test] + #[serial] + fn double_unpublish_returns_not_published() { + let _ = unpublish(); + publish(b"test".to_vec()).unwrap(); + unpublish().unwrap(); + assert!(matches!(unpublish(), Err(PublishError::NotPublished))); + } + + #[test] + #[cfg(target_os = "linux")] + fn try_memfd_mapping_returns_some() { + use std::num::NonZeroUsize; + let len = NonZeroUsize::new(32).unwrap(); + let ptr = super::try_memfd_mapping(len).expect("memfd_create + mmap should succeed"); + unsafe { nix::sys::mman::munmap(ptr, 32).unwrap() }; + } +} diff --git a/opentelemetry-process-context/rust/src/convert.rs b/opentelemetry-process-context/rust/src/convert.rs new file mode 100644 index 0000000000..159948abac --- /dev/null +++ b/opentelemetry-process-context/rust/src/convert.rs @@ -0,0 +1,202 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::proto::common::v1::{AnyValue, ArrayValue, KeyValue, KeyValueList, any_value}; +use crate::proto::processcontext::v1development::ProcessContext; +use crate::proto::resource::v1::Resource; +use prost::Message; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::types::{PyBool, PyByteArray, PyBytes, PyDict, PyFloat, PyInt, PyString}; + +fn any_value_from_py(val: &Bound<'_, PyAny>) -> PyResult { + if val.is_none() { + return Ok(AnyValue::default()); + } + if val.is_instance_of::() { + return Ok(AnyValue { + value: Some(any_value::Value::BoolValue(val.extract()?)), + }); + } + if val.is_instance_of::() { + return Ok(AnyValue { + value: Some(any_value::Value::IntValue(val.extract()?)), + }); + } + if val.is_instance_of::() { + return Ok(AnyValue { + value: Some(any_value::Value::DoubleValue(val.extract()?)), + }); + } + if val.is_instance_of::() { + return Ok(AnyValue { + value: Some(any_value::Value::StringValue(val.extract()?)), + }); + } + // `bytes`/`bytearray` are `Sequence`s, so this must precede the Sequence + // branch below or they would be expanded into an array of integers. + if val.is_instance_of::() || val.is_instance_of::() { + return Ok(AnyValue { + value: Some(any_value::Value::BytesValue(val.extract()?)), + }); + } + + let py = val.py(); + let collections_abc = py.import("collections.abc")?; + + if val.is_instance(collections_abc.getattr("Mapping")?.as_ref())? { + return Ok(AnyValue { + value: Some(any_value::Value::KvlistValue(KeyValueList { + values: key_values_from_py(val)?, + })), + }); + } + + if val.is_instance(collections_abc.getattr("Sequence")?.as_ref())? { + let values = val + .try_iter()? + .map(|item| any_value_from_py(&item?)) + .collect::>()?; + return Ok(AnyValue { + value: Some(any_value::Value::ArrayValue(ArrayValue { values })), + }); + } + + let type_name: String = val.get_type().qualname()?.extract()?; + Err(PyTypeError::new_err(format!( + "unsupported attribute value type: {type_name}" + ))) +} + +/// Convert a Python `Mapping[str, Any]` into a list of protobuf `KeyValue`s, +/// recursing into nested values via [`any_value_from_py`]. +pub fn key_values_from_py(mapping: &Bound<'_, PyAny>) -> PyResult> { + let py = mapping.py(); + let py_dict = py + .import("builtins")? + .getattr("dict")? + .call1((mapping,))? + .cast_into::()?; + + py_dict + .iter() + .map(|(key, val)| { + Ok(KeyValue { + key: key.str()?.extract()?, + value: Some(any_value_from_py(&val)?), + ..Default::default() + }) + }) + .collect() +} + +pub fn resource_from_py(resource: &Bound<'_, PyAny>) -> PyResult { + Ok(Resource { + attributes: key_values_from_py(&resource.getattr("attributes")?)?, + ..Default::default() + }) +} + +pub fn encode_process_context(resource: Resource, attributes: Vec) -> Vec { + ProcessContext { + resource: Some(resource), + attributes, + } + .encode_to_vec() +} + +#[cfg(test)] +mod tests { + use super::encode_process_context; + use crate::proto::common::v1::{AnyValue, KeyValue, any_value}; + use crate::proto::processcontext::v1development::ProcessContext; + use crate::proto::resource::v1::Resource; + use prost::Message; + + fn string_kv(key: &str, value: &str) -> KeyValue { + KeyValue { + key: key.into(), + value: Some(AnyValue { + value: Some(any_value::Value::StringValue(value.into())), + }), + ..Default::default() + } + } + + #[test] + fn encode_empty_resource_roundtrip() { + let bytes = encode_process_context(Resource::default(), vec![]); + let ctx = ProcessContext::decode(bytes.as_slice()).unwrap(); + assert!(ctx.resource.is_some()); + assert!(ctx.resource.unwrap().attributes.is_empty()); + assert!(ctx.attributes.is_empty()); + } + + #[test] + fn encode_with_additional_attributes_roundtrip() { + let resource = Resource { + attributes: vec![string_kv("service.name", "my-service")], + ..Default::default() + }; + let bytes = + encode_process_context(resource, vec![string_kv("deployment.environment", "prod")]); + let ctx = ProcessContext::decode(bytes.as_slice()).unwrap(); + + // Resource attributes and the additional attributes are kept separate. + let resource_attrs = ctx.resource.unwrap().attributes; + assert_eq!(resource_attrs.len(), 1); + assert_eq!(resource_attrs[0].key, "service.name"); + + assert_eq!(ctx.attributes.len(), 1); + assert_eq!(ctx.attributes[0].key, "deployment.environment"); + assert!(matches!( + ctx.attributes[0].value.as_ref().unwrap().value, + Some(any_value::Value::StringValue(ref s)) if s == "prod" + )); + } + + #[test] + fn encode_resource_with_string_attribute_roundtrip() { + let resource = Resource { + attributes: vec![KeyValue { + key: "service.name".into(), + value: Some(AnyValue { + value: Some(any_value::Value::StringValue("my-service".into())), + }), + ..Default::default() + }], + ..Default::default() + }; + let bytes = encode_process_context(resource, vec![]); + let ctx = ProcessContext::decode(bytes.as_slice()).unwrap(); + let attrs = ctx.resource.unwrap().attributes; + assert_eq!(attrs.len(), 1); + assert_eq!(attrs[0].key, "service.name"); + assert!(matches!( + attrs[0].value.as_ref().unwrap().value, + Some(any_value::Value::StringValue(ref s)) if s == "my-service" + )); + } + + #[test] + fn encode_resource_with_bytes_attribute_roundtrip() { + let resource = Resource { + attributes: vec![KeyValue { + key: "raw".into(), + value: Some(AnyValue { + value: Some(any_value::Value::BytesValue(vec![1, 2, 3])), + }), + ..Default::default() + }], + ..Default::default() + }; + let bytes = encode_process_context(resource, vec![]); + let ctx = ProcessContext::decode(bytes.as_slice()).unwrap(); + let attrs = ctx.resource.unwrap().attributes; + assert_eq!(attrs.len(), 1); + assert!(matches!( + attrs[0].value.as_ref().unwrap().value, + Some(any_value::Value::BytesValue(ref b)) if b == &[1, 2, 3] + )); + } +} diff --git a/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.common.v1.rs b/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.common.v1.rs new file mode 100644 index 0000000000..204efeb3a8 --- /dev/null +++ b/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.common.v1.rs @@ -0,0 +1,160 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// @generated by scripts/proto_codegen_process_context.sh from open-telemetry/opentelemetry-proto. DO NOT EDIT. + +// @generated +// This file is @generated by prost-build. +/// Represents any type of attribute value. AnyValue may contain a +/// primitive value such as a string or integer or it may contain an arbitrary nested +/// object containing arrays, key-value lists and primitives. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AnyValue { + /// The value is one of the listed fields. It is valid for all values to be unspecified + /// in which case this AnyValue is considered to be "empty". + #[prost(oneof="any_value::Value", tags="1, 2, 3, 4, 5, 6, 7, 8")] + pub value: ::core::option::Option, +} +/// Nested message and enum types in `AnyValue`. +pub mod any_value { + /// The value is one of the listed fields. It is valid for all values to be unspecified + /// in which case this AnyValue is considered to be "empty". + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Value { + #[prost(string, tag="1")] + StringValue(::prost::alloc::string::String), + #[prost(bool, tag="2")] + BoolValue(bool), + #[prost(int64, tag="3")] + IntValue(i64), + #[prost(double, tag="4")] + DoubleValue(f64), + #[prost(message, tag="5")] + ArrayValue(super::ArrayValue), + #[prost(message, tag="6")] + KvlistValue(super::KeyValueList), + #[prost(bytes, tag="7")] + BytesValue(::prost::alloc::vec::Vec), + /// Reference to the string value in ProfilesDictionary.string_table. + /// + /// Note: This is currently used exclusively in the Profiling signal. + /// Implementers of OTLP receivers for signals other than Profiling should + /// treat the presence of this value as a non-fatal issue. + /// Log an error or warning indicating an unexpected field intended for the + /// Profiling signal and process the data as if this value were absent or + /// empty, ignoring its semantic content for the non-Profiling signal. + /// + /// Status: \[Alpha\] + #[prost(int32, tag="8")] + StringValueStrindex(i32), + } +} +/// ArrayValue is a list of AnyValue messages. We need ArrayValue as a message +/// since oneof in AnyValue does not allow repeated fields. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ArrayValue { + /// Array of values. The array may be empty (contain 0 elements). + #[prost(message, repeated, tag="1")] + pub values: ::prost::alloc::vec::Vec, +} +/// KeyValueList is a list of KeyValue messages. We need KeyValueList as a message +/// since `oneof` in AnyValue does not allow repeated fields. Everywhere else where we need +/// a list of KeyValue messages (e.g. in Span) we use `repeated KeyValue` directly to +/// avoid unnecessary extra wrapping (which slows down the protocol). The 2 approaches +/// are semantically equivalent. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct KeyValueList { + /// A collection of key/value pairs of key-value pairs. The list may be empty (may + /// contain 0 elements). + /// + /// The keys MUST be unique (it is not allowed to have more than one + /// value with the same key). + /// The behavior of software that receives duplicated keys can be unpredictable. + #[prost(message, repeated, tag="1")] + pub values: ::prost::alloc::vec::Vec, +} +/// Represents a key-value pair that is used to store Span attributes, Link +/// attributes, etc. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct KeyValue { + /// The key name of the pair. + /// key_strindex MUST NOT be set if key is used. + #[prost(string, tag="1")] + pub key: ::prost::alloc::string::String, + /// The value of the pair. + #[prost(message, optional, tag="2")] + pub value: ::core::option::Option, + /// Reference to the string key in ProfilesDictionary.string_table. + /// key MUST NOT be set if key_strindex is used. + /// + /// Note: This is currently used exclusively in the Profiling signal. + /// Implementers of OTLP receivers for signals other than Profiling should + /// treat the presence of this key as a non-fatal issue. + /// Log an error or warning indicating an unexpected field intended for the + /// Profiling signal and process the data as if this value were absent or + /// empty, ignoring its semantic content for the non-Profiling signal. + /// + /// Status: \[Alpha\] + #[prost(int32, tag="3")] + pub key_strindex: i32, +} +/// InstrumentationScope is a message representing the instrumentation scope information +/// such as the fully qualified name and version. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct InstrumentationScope { + /// A name denoting the Instrumentation scope. + /// An empty instrumentation scope name means the name is unknown. + #[prost(string, tag="1")] + pub name: ::prost::alloc::string::String, + /// Defines the version of the instrumentation scope. + /// An empty instrumentation scope version means the version is unknown. + #[prost(string, tag="2")] + pub version: ::prost::alloc::string::String, + /// Additional attributes that describe the scope. \[Optional\]. + /// Attribute keys MUST be unique (it is not allowed to have more than one + /// attribute with the same key). + /// The behavior of software that receives duplicated keys can be unpredictable. + #[prost(message, repeated, tag="3")] + pub attributes: ::prost::alloc::vec::Vec, + /// The number of attributes that were discarded. Attributes + /// can be discarded because their keys are too long or because there are too many + /// attributes. If this value is 0, then no attributes were dropped. + #[prost(uint32, tag="4")] + pub dropped_attributes_count: u32, +} +/// A reference to an Entity. +/// Entity represents an object of interest associated with produced telemetry: e.g spans, metrics, profiles, or logs. +/// +/// Status: \[Development\] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EntityRef { + /// The Schema URL, if known. This is the identifier of the Schema that the entity data + /// is recorded in. To learn more about Schema URL see + /// + /// + /// This schema_url applies to the data in this message and to the Resource attributes + /// referenced by id_keys and description_keys. + /// TODO: discuss if we are happy with this somewhat complicated definition of what + /// the schema_url applies to. + /// + /// This field obsoletes the schema_url field in ResourceMetrics/ResourceSpans/ResourceLogs. + #[prost(string, tag="1")] + pub schema_url: ::prost::alloc::string::String, + /// Defines the type of the entity. MUST not change during the lifetime of the entity. + /// For example: "service" or "host". This field is required and MUST not be empty + /// for valid entities. + #[prost(string, tag="2")] + pub r#type: ::prost::alloc::string::String, + /// Attribute Keys that identify the entity. + /// MUST not change during the lifetime of the entity. The Id must contain at least one attribute. + /// These keys MUST exist in the containing {message}.attributes. + #[prost(string, repeated, tag="3")] + pub id_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Descriptive (non-identifying) attribute keys of the entity. + /// MAY change over the lifetime of the entity. MAY be empty. + /// These attribute keys are not part of entity's identity. + /// These keys MUST exist in the containing {message}.attributes. + #[prost(string, repeated, tag="4")] + pub description_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +// @@protoc_insertion_point(module) \ No newline at end of file diff --git a/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.processcontext.v1development.rs b/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.processcontext.v1development.rs new file mode 100644 index 0000000000..34185ee662 --- /dev/null +++ b/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.processcontext.v1development.rs @@ -0,0 +1,38 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// @generated by scripts/proto_codegen_process_context.sh from open-telemetry/opentelemetry-proto. DO NOT EDIT. + +// @generated +// This file is @generated by prost-build. +/// ProcessContext represents the payload for the process context sharing mechanism. +/// +/// This message is designed to be published by OpenTelemetry SDKs via a memory-mapped +/// region, allowing external readers (such as the OpenTelemetry eBPF Profiler) to +/// discover and read resource attributes from instrumented processes without requiring +/// direct integration or process activity. It is not part of OTLP and is not +/// exchanged via the OpenTelemetry Collector. +/// +/// See OTEP 4719 () +/// for details of this mechanism. +/// +/// Status: \[Development\] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ProcessContext { + /// The resource for this process. + /// If this field is not set then no resource info is known. + #[prost(message, optional, tag="1")] + pub resource: ::core::option::Option, + /// Additional attributes to share with external readers that are not part of + /// the standard Resource. \[Optional\] + /// + /// This field allows publishers to include supplementary key-value pairs that + /// may be useful for external readers but are not part of the SDK's configured + /// Resource. + /// + /// Consider adding any keys here to the profiles semantic conventions in + /// + #[prost(message, repeated, tag="2")] + pub attributes: ::prost::alloc::vec::Vec, +} +// @@protoc_insertion_point(module) \ No newline at end of file diff --git a/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.resource.v1.rs b/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.resource.v1.rs new file mode 100644 index 0000000000..2f3078160e --- /dev/null +++ b/opentelemetry-process-context/rust/src/generated/opentelemetry.proto.resource.v1.rs @@ -0,0 +1,29 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// @generated by scripts/proto_codegen_process_context.sh from open-telemetry/opentelemetry-proto. DO NOT EDIT. + +// @generated +// This file is @generated by prost-build. +/// Resource information. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Resource { + /// Set of attributes that describe the resource. + /// Attribute keys MUST be unique (it is not allowed to have more than one + /// attribute with the same key). + /// The behavior of software that receives duplicated keys can be unpredictable. + #[prost(message, repeated, tag="1")] + pub attributes: ::prost::alloc::vec::Vec, + /// The number of dropped attributes. If the value is 0, then + /// no attributes were dropped. + #[prost(uint32, tag="2")] + pub dropped_attributes_count: u32, + /// Set of entities that participate in this Resource. + /// + /// Note: keys in the references MUST exist in attributes of this message. + /// + /// Status: \[Development\] + #[prost(message, repeated, tag="3")] + pub entity_refs: ::prost::alloc::vec::Vec, +} +// @@protoc_insertion_point(module) \ No newline at end of file diff --git a/opentelemetry-process-context/rust/src/lib.rs b/opentelemetry-process-context/rust/src/lib.rs new file mode 100644 index 0000000000..0d7c2eb3c2 --- /dev/null +++ b/opentelemetry-process-context/rust/src/lib.rs @@ -0,0 +1,54 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(all(unix, target_has_atomic = "64"))] +mod context; +mod convert; +pub(crate) mod proto; + +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (resource, attributes = None))] +fn publish_context( + resource: &Bound<'_, PyAny>, + attributes: Option<&Bound<'_, PyAny>>, +) -> PyResult<()> { + #[cfg(all(unix, target_has_atomic = "64"))] + { + let resource = convert::resource_from_py(resource)?; + let attributes = match attributes { + Some(attributes) => convert::key_values_from_py(attributes)?, + None => Vec::new(), + }; + context::publish(convert::encode_process_context(resource, attributes))?; + Ok(()) + } + #[cfg(not(all(unix, target_has_atomic = "64")))] + { + let _ = (resource, attributes); + Err(pyo3::exceptions::PyRuntimeError::new_err( + "process context publication requires a Unix-like OS with 64 bit atomic support", + )) + } +} + +#[pyfunction] +fn unpublish_context() -> PyResult<()> { + #[cfg(all(unix, target_has_atomic = "64"))] + { + context::unpublish()?; + Ok(()) + } + #[cfg(not(all(unix, target_has_atomic = "64")))] + Err(pyo3::exceptions::PyRuntimeError::new_err( + "process context publication requires a Unix-like OS with 64 bit atomic support", + )) +} + +#[pymodule] +#[pyo3(name = "_rs")] +fn init(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_wrapped(wrap_pyfunction!(publish_context))?; + m.add_wrapped(wrap_pyfunction!(unpublish_context)) +} diff --git a/opentelemetry-process-context/rust/src/proto.rs b/opentelemetry-process-context/rust/src/proto.rs new file mode 100644 index 0000000000..e3b4c97fe5 --- /dev/null +++ b/opentelemetry-process-context/rust/src/proto.rs @@ -0,0 +1,22 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code, clippy::all)] + +pub(crate) mod common { + pub(crate) mod v1 { + include!("generated/opentelemetry.proto.common.v1.rs"); + } +} + +pub(crate) mod resource { + pub(crate) mod v1 { + include!("generated/opentelemetry.proto.resource.v1.rs"); + } +} + +pub(crate) mod processcontext { + pub(crate) mod v1development { + include!("generated/opentelemetry.proto.processcontext.v1development.rs"); + } +} diff --git a/opentelemetry-process-context/src/opentelemetry/process_context/__init__.py b/opentelemetry-process-context/src/opentelemetry/process_context/__init__.py new file mode 100644 index 0000000000..448738b728 --- /dev/null +++ b/opentelemetry-process-context/src/opentelemetry/process_context/__init__.py @@ -0,0 +1,9 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from opentelemetry.process_context._rs import ( + publish_context, + unpublish_context, +) + +__all__ = ["publish_context", "unpublish_context"] diff --git a/opentelemetry-process-context/src/opentelemetry/process_context/_rs/__init__.pyi b/opentelemetry-process-context/src/opentelemetry/process_context/_rs/__init__.pyi new file mode 100644 index 0000000000..cc9563685e --- /dev/null +++ b/opentelemetry-process-context/src/opentelemetry/process_context/_rs/__init__.pyi @@ -0,0 +1,33 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from opentelemetry.sdk.resources import Resource +from opentelemetry.util.types import Attributes + +def publish_context(resource: Resource, attributes: Attributes = None) -> None: + """Publish or update the process context for the given resource. + + Encodes ``resource`` as a protobuf ``ProcessContext`` message and writes it + to a named memory mapping (``OTEL_CTX``) that out-of-process readers such + as the OpenTelemetry eBPF Profiler can discover via ``/proc//maps``. + + On the first call the mapping is created and the full header is written. On + subsequent calls the existing mapping is updated in place using the spec's + update protocol, so no new mapping is allocated and the header pointer + remains stable across updates. + + :param resource: The SDK resource whose attributes are to be published. + :param attributes: Optional supplementary attributes to share with external + readers. + :raises OSError: If the memory mapping or clock could not be initialized. + """ + +def unpublish_context() -> None: + """Remove the published process context. + + Zeros the publish timestamp and unmaps the ``OTEL_CTX`` memory region. + After this call returns, :func:`publish_context` may be called again. + + :raises RuntimeError: If no context has been published yet. + :raises OSError: If unmapping the memory region failed. + """ diff --git a/opentelemetry-process-context/src/opentelemetry/process_context/py.typed b/opentelemetry-process-context/src/opentelemetry/process_context/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/opentelemetry-process-context/src/opentelemetry/process_context/version.py b/opentelemetry-process-context/src/opentelemetry/process_context/version.py new file mode 100644 index 0000000000..357a2a12e6 --- /dev/null +++ b/opentelemetry-process-context/src/opentelemetry/process_context/version.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.65b0.dev" diff --git a/opentelemetry-process-context/test-requirements.in b/opentelemetry-process-context/test-requirements.in new file mode 100644 index 0000000000..00297963bf --- /dev/null +++ b/opentelemetry-process-context/test-requirements.in @@ -0,0 +1,9 @@ +iniconfig==2.3.0 +packaging==26.2 +pluggy==1.6.0 +pytest==7.4.4 +-e opentelemetry-api +-e opentelemetry-sdk +-e opentelemetry-semantic-conventions +-e opentelemetry-proto +-e opentelemetry-process-context diff --git a/opentelemetry-process-context/test-requirements.txt b/opentelemetry-process-context/test-requirements.txt new file mode 100644 index 0000000000..e67e85ce84 --- /dev/null +++ b/opentelemetry-process-context/test-requirements.txt @@ -0,0 +1,48 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --python 3.10 --universal --resolution highest opentelemetry-process-context/test-requirements.in -o opentelemetry-process-context/test-requirements.txt +-e opentelemetry-api + # via + # -r opentelemetry-process-context/test-requirements.in + # opentelemetry-process-context + # opentelemetry-sdk + # opentelemetry-semantic-conventions +-e opentelemetry-process-context + # via -r opentelemetry-process-context/test-requirements.in +-e opentelemetry-proto + # via -r opentelemetry-process-context/test-requirements.in +-e opentelemetry-sdk + # via + # -r opentelemetry-process-context/test-requirements.in + # opentelemetry-process-context +-e opentelemetry-semantic-conventions + # via + # -r opentelemetry-process-context/test-requirements.in + # opentelemetry-sdk +colorama==0.4.6 ; sys_platform == 'win32' + # via pytest +exceptiongroup==1.3.1 ; python_full_version < '3.11' + # via pytest +iniconfig==2.3.0 + # via + # -r opentelemetry-process-context/test-requirements.in + # pytest +packaging==26.2 + # via + # -r opentelemetry-process-context/test-requirements.in + # pytest +pluggy==1.6.0 + # via + # -r opentelemetry-process-context/test-requirements.in + # pytest +protobuf==7.35.1 + # via opentelemetry-proto +pytest==7.4.4 + # via -r opentelemetry-process-context/test-requirements.in +tomli==2.4.1 ; python_full_version < '3.11' + # via pytest +typing-extensions==4.15.0 + # via + # exceptiongroup + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions diff --git a/opentelemetry-process-context/tests/__init__.py b/opentelemetry-process-context/tests/__init__.py new file mode 100644 index 0000000000..e57cf4aba9 --- /dev/null +++ b/opentelemetry-process-context/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/opentelemetry-process-context/tests/scripts/fork_keepalive.py b/opentelemetry-process-context/tests/scripts/fork_keepalive.py new file mode 100644 index 0000000000..8479b941ff --- /dev/null +++ b/opentelemetry-process-context/tests/scripts/fork_keepalive.py @@ -0,0 +1,27 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access + +import os +import signal +import sys + +from opentelemetry.process_context import publish_context +from opentelemetry.sdk.resources import Resource + +resource = Resource({"service.name": "otel-test-service", "version": 42}) +publish_context(resource) + +pid = os.fork() +if not pid: + # Child: do not publish, block until the parent kills us. + signal.pause() + os._exit(0) + +sys.stdout.write(f"{os.getpid()} {pid}\n") +sys.stdout.flush() +sys.stdin.readline() +os.kill(pid, signal.SIGKILL) +os.waitpid(pid, 0) +os._exit(0) diff --git a/opentelemetry-process-context/tests/scripts/fork_republish.py b/opentelemetry-process-context/tests/scripts/fork_republish.py new file mode 100644 index 0000000000..944b8f2200 --- /dev/null +++ b/opentelemetry-process-context/tests/scripts/fork_republish.py @@ -0,0 +1,34 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access,bare-except + +import os +import sys + +from opentelemetry.process_context import publish_context, unpublish_context +from opentelemetry.sdk.resources import Resource + +resource = Resource({"service.name": "test", "version": 1}) +publish_context(resource) + +pid = os.fork() +if not pid: + try: + publish_context(resource) + unpublish_context() + except: # noqa: E722 + os._exit(1) + os._exit(0) + +_, status = os.waitpid(pid, 0) +if not (os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0): + sys.exit(1) + +# The parent's own mapping is untouched and still usable. +try: + publish_context(resource) + unpublish_context() +except: # noqa: E722 + sys.exit(1) +sys.exit(0) diff --git a/opentelemetry-process-context/tests/scripts/fork_unpublish_without_publish.py b/opentelemetry-process-context/tests/scripts/fork_unpublish_without_publish.py new file mode 100644 index 0000000000..d44e820c7a --- /dev/null +++ b/opentelemetry-process-context/tests/scripts/fork_unpublish_without_publish.py @@ -0,0 +1,37 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access,bare-except + +import os +import sys + +from opentelemetry.process_context import publish_context, unpublish_context +from opentelemetry.sdk.resources import Resource + +resource = Resource({"service.name": "test", "version": 1}) +publish_context(resource) + +pid = os.fork() +if not pid: + # Child inherits the parent's region (stale owner PID). It never published, + # so unpublish must raise RuntimeError (NotPublished) rather than crash. + try: + unpublish_context() + except RuntimeError: + os._exit(0) + except: # noqa: E722 + os._exit(2) + # No exception at all is also wrong: the child published nothing. + os._exit(1) + +_, status = os.waitpid(pid, 0) +if not (os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0): + sys.exit(1) + +# The parent's own mapping is untouched and still usable. +try: + unpublish_context() +except: # noqa: E722 + sys.exit(1) +sys.exit(0) diff --git a/opentelemetry-process-context/tests/scripts/publish_and_wait.py b/opentelemetry-process-context/tests/scripts/publish_and_wait.py new file mode 100644 index 0000000000..e6cf49da18 --- /dev/null +++ b/opentelemetry-process-context/tests/scripts/publish_and_wait.py @@ -0,0 +1,14 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import sys + +from opentelemetry.process_context import publish_context +from opentelemetry.sdk.resources import Resource + +resource = Resource({"service.name": "otel-test-service", "version": 42}) +publish_context(resource, {"deployment.environment": "otel-test-env"}) + +sys.stdout.write("ready\n") +sys.stdout.flush() +sys.stdin.readline() diff --git a/opentelemetry-process-context/tests/scripts/publisher.py b/opentelemetry-process-context/tests/scripts/publisher.py new file mode 100644 index 0000000000..3b7e5ae858 --- /dev/null +++ b/opentelemetry-process-context/tests/scripts/publisher.py @@ -0,0 +1,26 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +from opentelemetry.process_context import publish_context, unpublish_context +from opentelemetry.sdk.resources import Resource + +first = Resource({"service.name": "otel-first", "version": 1}) +second = Resource({"service.name": "otel-second", "version": 2}) +publish_context(first) + +sys.stdout.write(f"{os.getpid()}\n") +sys.stdout.flush() + +for line in sys.stdin: + match line.strip(): + case "update": + publish_context(second) + case "unpublish": + unpublish_context() + case "exit": + break + sys.stdout.write("done\n") + sys.stdout.flush() diff --git a/opentelemetry-process-context/tests/test_process_context.py b/opentelemetry-process-context/tests/test_process_context.py new file mode 100644 index 0000000000..f0e962d5af --- /dev/null +++ b/opentelemetry-process-context/tests/test_process_context.py @@ -0,0 +1,282 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import struct +import subprocess +import sys +import threading +import unittest +from pathlib import Path + +from opentelemetry.process_context import ( + publish_context, + unpublish_context, +) +from opentelemetry.sdk.resources import Resource + +HEADER_SIZE = 32 + +# Helper scripts (see tests/scripts/). +_SCRIPTS_DIR = Path(__file__).parent / "scripts" + + +def _script_cmd(name: str) -> list[str]: + """Build the command to run a helper script under the current interpreter.""" + return [sys.executable, str(_SCRIPTS_DIR / name)] + + +def _read_header(pid: int, addr: int) -> dict: + with open(f"/proc/{pid}/mem", "rb") as mem: + mem.seek(addr) + header_bytes = mem.read(HEADER_SIZE) + + payload_size = struct.unpack_from(" int | None: + with open(f"/proc/{pid}/maps", encoding="utf-8") as maps_file: + for maps_line in maps_file: + if ":OTEL_CTX" in maps_line: + return int(maps_line.split("-")[0], 16) + return None + + +class TestPublishContext(unittest.TestCase): + def tearDown(self): + try: + unpublish_context() + except RuntimeError: + pass + + def test_publish_context_lifecycle(self): + resource = Resource( + {"service.name": "test", "version": 1, "pi": 3.14, "active": True} + ) + self.assertIsNone(publish_context(resource)) + self.assertIsNone(publish_context(resource)) + self.assertIsNone(publish_context(resource)) + + self.assertIsNone(unpublish_context()) + self.assertIsNone(publish_context(resource)) + + def test_publish_context_with_attributes(self): + resource = Resource({"service.name": "test"}) + self.assertIsNone( + publish_context(resource, {"deployment.environment": "prod"}) + ) + self.assertIsNone( + publish_context(resource, {"k": 1, "nested": {"a": 2}}) + ) + self.assertIsNone(publish_context(resource)) + + def test_unpublish_before_publish_raises(self): + with self.assertRaises(RuntimeError): + unpublish_context() + + def test_concurrent_publish(self): + """Many threads publishing concurrently must not crash or error.""" + thread_count = 8 + iterations = 200 + barrier = threading.Barrier(thread_count) + errors: list[BaseException] = [] + + def worker(index: int) -> None: + resource = Resource( + {"service.name": f"svc-{index}", "version": index} + ) + barrier.wait() + try: + for _ in range(iterations): + publish_context(resource) + # pylint: disable-next=broad-except + except BaseException as exc: + errors.append(exc) + + threads = [ + threading.Thread(target=worker, args=(i,)) + for i in range(thread_count) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(errors, []) + # The mapping is still in a consistent, usable state. + self.assertIsNone(publish_context(Resource({"service.name": "final"}))) + self.assertIsNone(unpublish_context()) + + @unittest.skipUnless(hasattr(os, "fork"), "requires os.fork") + def test_publish_in_forked_child(self): + """A child that re-publishes after fork must not crash and the parent + must remain unaffected.""" + result = subprocess.run(_script_cmd("fork_republish.py"), check=False) + self.assertEqual( + result.returncode, + 0, + "fork/re-publish script did not exit cleanly", + ) + + @unittest.skipUnless(hasattr(os, "fork"), "requires os.fork") + def test_unpublish_in_forked_child_without_publish(self): + """A child that inherits the parent's region but never publishes must + get NotPublished from unpublish (not a crash), and the parent stays + usable.""" + result = subprocess.run( + _script_cmd("fork_unpublish_without_publish.py"), check=False + ) + self.assertEqual( + result.returncode, + 0, + "fork/unpublish-without-publish script did not exit cleanly", + ) + + @unittest.skipUnless( + sys.platform.startswith("linux"), "requires /proc//{maps,mem}" + ) + def test_cross_process_memory_region(self): + """Spawn a child that publishes a fixed context and read/validate its memory region.""" + with subprocess.Popen( + _script_cmd("publish_and_wait.py"), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) as proc: + self.assertEqual(proc.stdout.readline(), b"ready\n") + + start_addr = _find_otel_ctx_addr(proc.pid) + self.assertIsNotNone( + start_addr, + f"OTEL_CTX mapping not found in /proc/{proc.pid}/maps", + ) + + header = _read_header(proc.pid, start_addr) + + self.assertEqual(header["signature"], b"OTEL_CTX") + self.assertEqual(header["version"], 2) + self.assertGreater(header["payload_size"], 0) + self.assertGreater(header["timestamp_ns"], 0) + self.assertNotEqual(header["payload_ptr"], 0) + self.assertIn(b"service.name", header["payload"]) + self.assertIn(b"otel-test-service", header["payload"]) + self.assertIn(b"deployment.environment", header["payload"]) + self.assertIn(b"otel-test-env", header["payload"]) + + @unittest.skipUnless( + sys.platform.startswith("linux"), "requires /proc//{maps,mem}" + ) + def test_mapping_present_in_parent_absent_in_child(self): + """After a fork the mapping must be visible in the parent but stripped + from the child (MADV_DONTFORK).""" + with subprocess.Popen( + _script_cmd("fork_keepalive.py"), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) as proc: + parent_pid, child_pid = ( + int(token) for token in proc.stdout.readline().split() + ) + + # Parent: mapping present in /proc/maps and readable via /proc/mem. + parent_addr = _find_otel_ctx_addr(parent_pid) + self.assertIsNotNone( + parent_addr, + "OTEL_CTX mapping missing from the parent", + ) + header = _read_header(parent_pid, parent_addr) + self.assertEqual(header["signature"], b"OTEL_CTX") + self.assertEqual(header["version"], 2) + + # Child: mapping absent from /proc/maps... + self.assertIsNone( + _find_otel_ctx_addr(child_pid), + "OTEL_CTX mapping leaked into the forked child", + ) + # The parent's address is unmapped in the child, so reading + # it from /proc//mem fails. + with self.assertRaises(OSError): + with open(f"/proc/{child_pid}/mem", "rb") as mem: + mem.seek(parent_addr) + mem.read(HEADER_SIZE) + + @unittest.skipUnless( + sys.platform.startswith("linux"), "requires /proc//{maps,mem}" + ) + def test_update_in_place_keeps_stable_mapping(self): + """An update reuses the same header mapping, advances the + timestamp and swaps in the new payload.""" + with subprocess.Popen( + _script_cmd("publisher.py"), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) as proc: + pid = int(proc.stdout.readline()) + + addr1 = _find_otel_ctx_addr(pid) + self.assertIsNotNone(addr1) + before = _read_header(pid, addr1) + self.assertIn(b"otel-first", before["payload"]) + + proc.stdin.write("update\n") + proc.stdin.flush() + self.assertEqual(proc.stdout.readline(), "done\n") + + addr2 = _find_otel_ctx_addr(pid) + self.assertEqual( + addr2, addr1, "header mapping moved across update" + ) + after = _read_header(pid, addr2) + + self.assertEqual(after["version"], 2) + self.assertNotEqual(after["payload_ptr"], 0) + self.assertGreater(before["timestamp_ns"], 0) + self.assertGreaterEqual( + after["timestamp_ns"], before["timestamp_ns"] + ) + self.assertIn(b"otel-second", after["payload"]) + + @unittest.skipUnless( + sys.platform.startswith("linux"), "requires /proc//{maps,mem}" + ) + def test_unpublish_removes_mapping(self): + """Unpublishing removes the mapping, a later publish recreates it.""" + with subprocess.Popen( + _script_cmd("publisher.py"), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) as proc: + pid = int(proc.stdout.readline()) + self.assertIsNotNone(_find_otel_ctx_addr(pid)) + + proc.stdin.write("unpublish\n") + proc.stdin.flush() + self.assertEqual(proc.stdout.readline(), "done\n") + self.assertIsNone( + _find_otel_ctx_addr(pid), + "OTEL_CTX mapping survived unpublish", + ) + + proc.stdin.write("update\n") + proc.stdin.flush() + self.assertEqual(proc.stdout.readline(), "done\n") + self.assertIsNotNone( + _find_otel_ctx_addr(pid), + "OTEL_CTX mapping not recreated after re-publish", + ) diff --git a/pyproject.toml b/pyproject.toml index 95c826e01b..070a8b825f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "opentelemetry-propagator-jaeger", "opentelemetry-propagator-b3", "opentelemetry-codegen-json", + "opentelemetry-process-context", ] # https://docs.astral.sh/uv/reference/settings/ @@ -45,6 +46,7 @@ opentelemetry-exporter-prometheus = {workspace = true } opentelemetry-propagator-jaeger = { workspace = true } opentelemetry-propagator-b3 = { workspace = true } opentelemetry-codegen-json = { workspace = true } +opentelemetry-process-context = { workspace = true } [tool.uv.workspace] members = [ @@ -57,6 +59,7 @@ members = [ "propagator/*", "codegen/*", "tests/opentelemetry-test-utils", + "opentelemetry-process-context", ] exclude = [ @@ -128,7 +131,8 @@ include = [ "exporter/opentelemetry-exporter-otlp-proto-grpc", "exporter/opentelemetry-exporter-otlp-proto-http", "exporter/opentelemetry-exporter-otlp-json-common", - "codegen/opentelemetry-codegen-json" + "codegen/opentelemetry-codegen-json", + "opentelemetry-process-context" ] exclude = [ @@ -148,6 +152,7 @@ exclude = [ "exporter/opentelemetry-exporter-otlp-proto-http/tests", "exporter/opentelemetry-exporter-otlp-json-common/tests", "exporter/opentelemetry-exporter-otlp-json-common/benchmarks", + "opentelemetry-process-context/tests" ] # When packages are correct typed add them to the strict list diff --git a/scripts/eachdist.py b/scripts/eachdist.py index c5f5e9880c..f716921196 100755 --- a/scripts/eachdist.py +++ b/scripts/eachdist.py @@ -570,28 +570,49 @@ def filter_packages(targets, packages): return filtered_packages -def update_version_files(targets, version, packages): - print("updating version/__init__.py files") - +def update_dunder_version_file(version_file_path, version): search = "__version__ .*" replace = f'__version__ = "{version}"' + with open(version_file_path) as file: + text = file.read() + + if replace in text: + print(f"{version_file_path} already contains {replace}") + return + + with open(version_file_path, "w", encoding="utf-8") as file: + file.write(re.sub(search, replace, text)) + + +def update_version_files(targets, version, packages): + print("updating version/__init__.py files") + for target in filter_packages(targets, packages): - version_file_path = target.joinpath( - load(target.joinpath("pyproject.toml"))["tool"]["hatch"][ - "version" - ]["path"] + pyproject = load(target.joinpath("pyproject.toml")) + hatch_version = ( + pyproject.get("tool", {}).get("hatch", {}).get("version") ) - with open(version_file_path) as file: - text = file.read() - - if replace in text: - print(f"{version_file_path} already contains {replace}") + if hatch_version: + # Hatch packages declare a dynamic version sourced from a file + # containing a `__version__` assignment. + update_dunder_version_file( + target.joinpath(hatch_version["path"]), version + ) continue - with open(version_file_path, "w", encoding="utf-8") as file: - file.write(re.sub(search, replace, text)) + # Maturin packages keep a static `version` under + # `[project]` in pyproject.toml as the source of truth and is also + # mirrored in a version.py for introspection. Update both. + update_files( + [target], + "pyproject.toml", + r'(?m)^version = ".*"$', + f'version = "{version}"', + ) + for version_file_path in target.glob("src/**/version.py"): + update_dunder_version_file(version_file_path, version) def update_dependencies(targets, version, packages): @@ -601,7 +622,9 @@ def update_dependencies(targets, version, packages): operators_pattern = "|".join(re.escape(op) for op in operators) for pkg in packages: - search = rf"({basename(pkg)}[^,]*)({operators_pattern})(.*\.dev)" + # `[^,\n]*` keeps the match on a single line so a package's own static + # `version = "...dev"` line (for Maturin packages) is not swallowed. + search = rf"({basename(pkg)}[^,\n]*)({operators_pattern})(.*\.dev)" replace = r"\1\2 " + version update_files( targets, @@ -618,7 +641,7 @@ def update_patch_dependencies(targets, version, prev_version, packages): operators_pattern = "|".join(re.escape(op) for op in operators) for pkg in packages: - search = rf"({basename(pkg)}[^,]*?)(\s?({operators_pattern})\s?)(.*{prev_version})" + search = rf"({basename(pkg)}[^,\n]*?)(\s?({operators_pattern})\s?)(.*{prev_version})" replace = r"\g<1>\g<2>" + version print(f"{search=}\t{replace=}\t{pkg=}") update_files( diff --git a/scripts/proto_codegen_process_context.sh b/scripts/proto_codegen_process_context.sh new file mode 100755 index 0000000000..576e2efb5e --- /dev/null +++ b/scripts/proto_codegen_process_context.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# +# Regenerate the Rust (prost) code for opentelemetry-process-context from the +# process context proto in https://github.com/open-telemetry/opentelemetry-proto +# +# To use, update PROTO_REPO_BRANCH_OR_COMMIT below to the commit hash/tag you want to +# build off of, then run this script and commit the regenerated files. +# +# Requirements: cargo (to install the protoc-gen-prost plugin) and uv/uvx (provides +# protoc via grpcio-tools, similar to the other proto scripts). + +# Pinned commit/branch/tag for the process context proto. +PROTO_REPO_BRANCH_OR_COMMIT="023f8cd36cc946617caa9a9e2e9868186f6d22dd" + +# protoc-gen-prost release that emits code compatible with the crate's prost 0.13. +PROTOC_GEN_PROST_VERSION="0.4.0" + +set -e + +PROTO_REPO_DIR=${PROTO_REPO_DIR:-"/tmp/opentelemetry-proto"} +# root of opentelemetry-python repo +repo_root="$(git rev-parse --show-toplevel)" +out_dir="$repo_root/opentelemetry-process-context/rust/src/generated" + +# The protos to generate. The process context message imports common and resource. +PROTOS=( + "opentelemetry/proto/common/v1/common.proto" + "opentelemetry/proto/resource/v1/resource.proto" + "opentelemetry/proto/processcontext/v1development/process_context.proto" +) + +# Install the prost code generator plugin for protoc. +cargo install "protoc-gen-prost@${PROTOC_GEN_PROST_VERSION}" + +# protoc, provided by grpcio-tools via uvx (same approach as scripts/proto_codegen.sh), +# wired up to the cargo-installed prost plugin. +protoc() { + uvx --from grpcio-tools \ + --python 3.12 \ + python -m grpc_tools.protoc \ + --plugin=protoc-gen-prost="$(command -v protoc-gen-prost)" \ + "$@" +} + +protoc --version + +# Clone the proto repo if it doesn't exist +if [ ! -d "$PROTO_REPO_DIR" ]; then + git clone https://github.com/open-telemetry/opentelemetry-proto.git "$PROTO_REPO_DIR" +fi + +# Pull in changes and switch to requested branch +( + cd "$PROTO_REPO_DIR" + git fetch --all + git checkout "$PROTO_REPO_BRANCH_OR_COMMIT" + # pull if PROTO_REPO_BRANCH_OR_COMMIT is not a detached head + git symbolic-ref -q HEAD && git pull --ff-only || true +) + +# Regenerate from scratch. +mkdir -p "$out_dir" +rm -f "$out_dir"/*.rs +protoc \ + -I "$PROTO_REPO_DIR" \ + --prost_out="$out_dir" \ + "${PROTOS[@]}" + +# Prepend the standard copyright header +header="// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// @generated by scripts/proto_codegen_process_context.sh from open-telemetry/opentelemetry-proto. DO NOT EDIT." +for f in "$out_dir"/*.rs; do + printf '%s\n\n%s' "$header" "$(cat "$f")" > "$f" +done + +echo "Regenerated prost output in $out_dir" diff --git a/tox.ini b/tox.ini index 5476de0cde..911bb9d0a8 100644 --- a/tox.ini +++ b/tox.ini @@ -96,6 +96,10 @@ envlist = pypy3-test-opentelemetry-exporter-zipkin-json lint-opentelemetry-exporter-zipkin-json + py3{10,11,12,13,14,14t}-test-opentelemetry-process-context + ; intentionally excluded from pypy3 (can't build) + lint-opentelemetry-process-context + py3{10,11,12,13,14,14t}-test-opentelemetry-propagator-b3 pypy3-test-opentelemetry-propagator-b3 lint-opentelemetry-propagator-b3 @@ -122,6 +126,9 @@ envlist = precommit [testenv] +platform = + test-opentelemetry-process-context: linux + deps = lint: -r dev-requirements.txt coverage: pytest @@ -176,6 +183,8 @@ deps = exporter-zipkin-json: -r {toxinidir}/exporter/opentelemetry-exporter-zipkin-json/test-requirements.txt + process-context: -r {toxinidir}/opentelemetry-process-context/test-requirements.txt + propagator-b3: -r {toxinidir}/propagator/opentelemetry-propagator-b3/test-requirements.txt benchmark-opentelemetry-propagator-b3: -r {toxinidir}/propagator/opentelemetry-propagator-b3/benchmark-requirements.txt @@ -281,6 +290,9 @@ commands = test-opentelemetry-exporter-zipkin-json: pytest {toxinidir}/exporter/opentelemetry-exporter-zipkin-json/tests {posargs} lint-opentelemetry-exporter-zipkin-json: sh -c "cd exporter && pylint --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-zipkin-json" + test-opentelemetry-process-context: pytest {toxinidir}/opentelemetry-process-context/tests {posargs} + lint-opentelemetry-process-context: pylint {toxinidir}/opentelemetry-process-context + test-opentelemetry-propagator-b3: pytest {toxinidir}/propagator/opentelemetry-propagator-b3/tests {posargs} lint-opentelemetry-propagator-b3: sh -c "cd propagator && pylint --rcfile ../.pylintrc {toxinidir}/propagator/opentelemetry-propagator-b3" benchmark-opentelemetry-propagator-b3: pytest {toxinidir}/propagator/opentelemetry-propagator-b3/benchmarks --benchmark-json=propagator-b3-benchmark.json {posargs} @@ -430,6 +442,7 @@ deps = -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http -e {toxinidir}/opentelemetry-proto -e {toxinidir}/opentelemetry-proto-json + -e {toxinidir}/opentelemetry-process-context -e {toxinidir}/codegen/opentelemetry-codegen-json commands = diff --git a/uv.lock b/uv.lock index ec806543d8..b8f5c273d2 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,7 @@ members = [ "opentelemetry-exporter-otlp-proto-http", "opentelemetry-exporter-prometheus", "opentelemetry-exporter-zipkin-json", + "opentelemetry-process-context", "opentelemetry-propagator-b3", "opentelemetry-propagator-jaeger", "opentelemetry-proto", @@ -1013,6 +1014,21 @@ requires-dist = [ { name = "requests", specifier = "~=2.7" }, ] +[[package]] +name = "opentelemetry-process-context" +version = "0.65b0.dev0" +source = { editable = "opentelemetry-process-context" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api", editable = "opentelemetry-api" }, + { name = "opentelemetry-sdk", editable = "opentelemetry-sdk" }, +] + [[package]] name = "opentelemetry-propagator-b3" source = { editable = "propagator/opentelemetry-propagator-b3" } @@ -1066,6 +1082,7 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-exporter-prometheus" }, { name = "opentelemetry-exporter-zipkin-json" }, + { name = "opentelemetry-process-context" }, { name = "opentelemetry-propagator-b3" }, { name = "opentelemetry-propagator-jaeger" }, { name = "opentelemetry-proto" }, @@ -1096,6 +1113,7 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", editable = "exporter/opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-exporter-prometheus", editable = "exporter/opentelemetry-exporter-prometheus" }, { name = "opentelemetry-exporter-zipkin-json", editable = "exporter/opentelemetry-exporter-zipkin-json" }, + { name = "opentelemetry-process-context", editable = "opentelemetry-process-context" }, { name = "opentelemetry-propagator-b3", editable = "propagator/opentelemetry-propagator-b3" }, { name = "opentelemetry-propagator-jaeger", editable = "propagator/opentelemetry-propagator-jaeger" }, { name = "opentelemetry-proto", editable = "opentelemetry-proto" },