From 9abf25caa7ab15edbbc31d31232ce680d50bfae1 Mon Sep 17 00:00:00 2001 From: Andrew Lewin Date: Mon, 10 Mar 2025 16:05:18 -0700 Subject: [PATCH 1/3] feat(exporter): Implement OTLP JSON exporters for HTTP transport Add new packages for JSON-based OTLP exporters as alternatives to the existing Protobuf-based exporters: - opentelemetry-exporter-otlp-json-common: Common JSON encoding functionality - opentelemetry-exporter-otlp-json-http: HTTP transport implementation These exporters enable compatibility with services requiring JSON format instead of Protobuf. Implementation includes full support for traces, metrics, and logs with comprehensive test coverage following project guidelines. Closes #1003 --- .github/workflows/lint_0.yml | 36 + .github/workflows/test_0.yml | 846 +++++++++--------- .github/workflows/test_1.yml | 588 ++++++++++++ .../LICENSE | 201 +++++ .../README.rst | 28 + .../pyproject.toml | 49 + .../exporter/otlp/json/common/__init__.py | 18 + .../otlp/json/common/_internal/__init__.py | 269 ++++++ .../common/_internal/_log_encoder/__init__.py | 269 ++++++ .../json/common/_internal/encoder_utils.py | 13 + .../_internal/metrics_encoder/__init__.py | 498 +++++++++++ .../_internal/trace_encoder/__init__.py | 346 +++++++ .../exporter/otlp/json/common/_log_encoder.py | 20 + .../otlp/json/common/metrics_encoder.py | 20 + .../otlp/json/common/trace_encoder.py | 20 + .../otlp/json/common/version/__init__.py | 15 + .../test-requirements.txt | 17 + .../tests/__init__.py | 13 + .../tests/test_attribute_encoder.py | 189 ++++ .../tests/test_backoff.py | 49 + .../tests/test_log_encoder.py | 265 ++++++ .../tests/test_metrics_encoder.py | 381 ++++++++ .../tests/test_trace_encoder.py | 232 +++++ .../README.rst | 91 ++ .../pyproject.toml | 60 ++ .../exporter/otlp/json/http/__init__.py | 58 ++ .../otlp/json/http/_log_exporter/__init__.py | 265 ++++++ .../json/http/metric_exporter/__init__.py | 293 ++++++ .../exporter/otlp/json/http/py.typed | 1 + .../otlp/json/http/trace_exporter/__init__.py | 309 +++++++ .../json/http/trace_exporter/constants.py | 20 + .../otlp/json/http/version/__init__.py | 15 + .../test-requirements.txt | 25 + .../tests/__init__.py | 13 + .../tests/test_json_log_exporter.py | 342 +++++++ .../tests/test_json_metrics_exporter.py | 359 ++++++++ .../tests/test_json_span_exporter.py | 336 +++++++ tox.ini | 18 + 38 files changed, 6143 insertions(+), 444 deletions(-) create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/LICENSE create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/README.rst create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/pyproject.toml create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/encoder_utils.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/test-requirements.txt create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/test_attribute_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/test_backoff.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/test_log_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/test_trace_encoder.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/README.rst create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/constants.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_log_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_metrics_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_span_exporter.py diff --git a/.github/workflows/lint_0.yml b/.github/workflows/lint_0.yml index 71b148c02d5..86a82d93743 100644 --- a/.github/workflows/lint_0.yml +++ b/.github/workflows/lint_0.yml @@ -178,6 +178,24 @@ jobs: - name: Run tests run: tox -e lint-opentelemetry-exporter-otlp-proto-common + lint-opentelemetry-exporter-otlp-json-common: + name: opentelemetry-exporter-otlp-json-common + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e lint-opentelemetry-exporter-otlp-json-common + lint-opentelemetry-exporter-otlp-combined: name: opentelemetry-exporter-otlp-combined runs-on: ubuntu-latest @@ -232,6 +250,24 @@ jobs: - name: Run tests run: tox -e lint-opentelemetry-exporter-otlp-proto-http + lint-opentelemetry-exporter-otlp-json-http: + name: opentelemetry-exporter-otlp-json-http + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e lint-opentelemetry-exporter-otlp-json-http + lint-opentelemetry-exporter-prometheus: name: opentelemetry-exporter-prometheus runs-on: ubuntu-latest diff --git a/.github/workflows/test_0.yml b/.github/workflows/test_0.yml index d24d7325349..f73325885a8 100644 --- a/.github/workflows/test_0.yml +++ b/.github/workflows/test_0.yml @@ -1096,6 +1096,132 @@ jobs: - name: Run tests run: tox -e pypy3-test-opentelemetry-exporter-otlp-proto-common -- -ra + py38-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common 3.8 Ubuntu + runs-on: ubuntu-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e py38-test-opentelemetry-exporter-otlp-json-common -- -ra + + py39-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common 3.9 Ubuntu + runs-on: ubuntu-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e py39-test-opentelemetry-exporter-otlp-json-common -- -ra + + py310-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common 3.10 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py310-test-opentelemetry-exporter-otlp-json-common -- -ra + + py311-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common 3.11 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py311-test-opentelemetry-exporter-otlp-json-common -- -ra + + py312-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common 3.12 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py312-test-opentelemetry-exporter-otlp-json-common -- -ra + + py313-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common 3.13 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py313-test-opentelemetry-exporter-otlp-json-common -- -ra + + pypy3-test-opentelemetry-exporter-otlp-json-common_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-common pypy-3.8 Ubuntu + runs-on: ubuntu-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.8 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.8" + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-common -- -ra + py38-test-opentelemetry-exporter-otlp-combined_ubuntu-latest: name: opentelemetry-exporter-otlp-combined 3.8 Ubuntu runs-on: ubuntu-latest @@ -1438,6 +1564,132 @@ jobs: - name: Run tests run: tox -e pypy3-test-opentelemetry-exporter-otlp-proto-http -- -ra + py38-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 3.8 Ubuntu + runs-on: ubuntu-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e py38-test-opentelemetry-exporter-otlp-json-http -- -ra + + py39-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 3.9 Ubuntu + runs-on: ubuntu-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e py39-test-opentelemetry-exporter-otlp-json-http -- -ra + + py310-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 3.10 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py310-test-opentelemetry-exporter-otlp-json-http -- -ra + + py311-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 3.11 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py311-test-opentelemetry-exporter-otlp-json-http -- -ra + + py312-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 3.12 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py312-test-opentelemetry-exporter-otlp-json-http -- -ra + + py313-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 3.13 Ubuntu + runs-on: ubuntu-latest + 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 + + - name: Run tests + run: tox -e py313-test-opentelemetry-exporter-otlp-json-http -- -ra + + pypy3-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http pypy-3.8 Ubuntu + runs-on: ubuntu-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.8 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.8" + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-http -- -ra + py38-test-opentelemetry-exporter-prometheus_ubuntu-latest: name: opentelemetry-exporter-prometheus 3.8 Ubuntu runs-on: ubuntu-latest @@ -3580,8 +3832,8 @@ jobs: - name: Run tests run: tox -e pypy3-test-opentelemetry-exporter-otlp-proto-common -- -ra - py38-test-opentelemetry-exporter-otlp-combined_windows-latest: - name: opentelemetry-exporter-otlp-combined 3.8 Windows + py38-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -3599,10 +3851,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-otlp-combined -- -ra + run: tox -e py38-test-opentelemetry-exporter-otlp-json-common -- -ra - py39-test-opentelemetry-exporter-otlp-combined_windows-latest: - name: opentelemetry-exporter-otlp-combined 3.9 Windows + py39-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -3620,10 +3872,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-otlp-combined -- -ra + run: tox -e py39-test-opentelemetry-exporter-otlp-json-common -- -ra - py310-test-opentelemetry-exporter-otlp-combined_windows-latest: - name: opentelemetry-exporter-otlp-combined 3.10 Windows + py310-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -3641,10 +3893,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-otlp-combined -- -ra + run: tox -e py310-test-opentelemetry-exporter-otlp-json-common -- -ra - py311-test-opentelemetry-exporter-otlp-combined_windows-latest: - name: opentelemetry-exporter-otlp-combined 3.11 Windows + py311-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -3662,10 +3914,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-otlp-combined -- -ra + run: tox -e py311-test-opentelemetry-exporter-otlp-json-common -- -ra - py312-test-opentelemetry-exporter-otlp-combined_windows-latest: - name: opentelemetry-exporter-otlp-combined 3.12 Windows + py312-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -3683,10 +3935,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-otlp-combined -- -ra + run: tox -e py312-test-opentelemetry-exporter-otlp-json-common -- -ra - py313-test-opentelemetry-exporter-otlp-combined_windows-latest: - name: opentelemetry-exporter-otlp-combined 3.13 Windows + py313-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -3704,19 +3956,19 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-otlp-combined -- -ra + run: tox -e py313-test-opentelemetry-exporter-otlp-json-common -- -ra - py38-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: - name: opentelemetry-exporter-otlp-proto-grpc 3.8 Windows + pypy3-test-opentelemetry-exporter-otlp-json-common_windows-latest: + name: opentelemetry-exporter-otlp-json-common pypy-3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python pypy-3.8 uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "pypy-3.8" - name: Install tox run: pip install tox @@ -3725,19 +3977,19 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-otlp-proto-grpc -- -ra + run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-common -- -ra - py39-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: - name: opentelemetry-exporter-otlp-proto-grpc 3.9 Windows + py38-test-opentelemetry-exporter-otlp-combined_windows-latest: + name: opentelemetry-exporter-otlp-combined 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.8 uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.8" - name: Install tox run: pip install tox @@ -3746,19 +3998,19 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-otlp-proto-grpc -- -ra + run: tox -e py38-test-opentelemetry-exporter-otlp-combined -- -ra - py310-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: - name: opentelemetry-exporter-otlp-proto-grpc 3.10 Windows + py39-test-opentelemetry-exporter-otlp-combined_windows-latest: + name: opentelemetry-exporter-otlp-combined 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.9" - name: Install tox run: pip install tox @@ -3767,262 +4019,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-otlp-proto-grpc -- -ra + run: tox -e py39-test-opentelemetry-exporter-otlp-combined -- -ra - py311-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: - name: opentelemetry-exporter-otlp-proto-grpc 3.11 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - - py312-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: - name: opentelemetry-exporter-otlp-proto-grpc 3.12 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - - py313-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: - name: opentelemetry-exporter-otlp-proto-grpc 3.13 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - - py38-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http 3.8 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python 3.8 - uses: actions/setup-python@v5 - with: - python-version: "3.8" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-otlp-proto-http -- -ra - - py39-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http 3.9 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python 3.9 - uses: actions/setup-python@v5 - with: - python-version: "3.9" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-otlp-proto-http -- -ra - - py310-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http 3.10 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-otlp-proto-http -- -ra - - py311-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http 3.11 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-otlp-proto-http -- -ra - - py312-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http 3.12 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-otlp-proto-http -- -ra - - py313-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http 3.13 Windows - runs-on: windows-latest - 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 - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-otlp-proto-http -- -ra - - pypy3-test-opentelemetry-exporter-otlp-proto-http_windows-latest: - name: opentelemetry-exporter-otlp-proto-http pypy-3.8 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python pypy-3.8 - uses: actions/setup-python@v5 - with: - python-version: "pypy-3.8" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e pypy3-test-opentelemetry-exporter-otlp-proto-http -- -ra - - py38-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus 3.8 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python 3.8 - uses: actions/setup-python@v5 - with: - python-version: "3.8" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-prometheus -- -ra - - py39-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus 3.9 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python 3.9 - uses: actions/setup-python@v5 - with: - python-version: "3.9" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-prometheus -- -ra - - py310-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus 3.10 Windows + py310-test-opentelemetry-exporter-otlp-combined_windows-latest: + name: opentelemetry-exporter-otlp-combined 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4040,10 +4040,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-prometheus -- -ra + run: tox -e py310-test-opentelemetry-exporter-otlp-combined -- -ra - py311-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus 3.11 Windows + py311-test-opentelemetry-exporter-otlp-combined_windows-latest: + name: opentelemetry-exporter-otlp-combined 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4061,10 +4061,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-prometheus -- -ra + run: tox -e py311-test-opentelemetry-exporter-otlp-combined -- -ra - py312-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus 3.12 Windows + py312-test-opentelemetry-exporter-otlp-combined_windows-latest: + name: opentelemetry-exporter-otlp-combined 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4082,10 +4082,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-prometheus -- -ra + run: tox -e py312-test-opentelemetry-exporter-otlp-combined -- -ra - py313-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus 3.13 Windows + py313-test-opentelemetry-exporter-otlp-combined_windows-latest: + name: opentelemetry-exporter-otlp-combined 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4103,31 +4103,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-prometheus -- -ra - - pypy3-test-opentelemetry-exporter-prometheus_windows-latest: - name: opentelemetry-exporter-prometheus pypy-3.8 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python pypy-3.8 - uses: actions/setup-python@v5 - with: - python-version: "pypy-3.8" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e pypy3-test-opentelemetry-exporter-prometheus -- -ra + run: tox -e py313-test-opentelemetry-exporter-otlp-combined -- -ra - py38-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined 3.8 Windows + py38-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: + name: opentelemetry-exporter-otlp-proto-grpc 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4145,10 +4124,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-zipkin-combined -- -ra + run: tox -e py38-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - py39-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined 3.9 Windows + py39-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: + name: opentelemetry-exporter-otlp-proto-grpc 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4166,10 +4145,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-zipkin-combined -- -ra + run: tox -e py39-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - py310-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined 3.10 Windows + py310-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: + name: opentelemetry-exporter-otlp-proto-grpc 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4187,10 +4166,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-zipkin-combined -- -ra + run: tox -e py310-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - py311-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined 3.11 Windows + py311-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: + name: opentelemetry-exporter-otlp-proto-grpc 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4208,10 +4187,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-zipkin-combined -- -ra + run: tox -e py311-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - py312-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined 3.12 Windows + py312-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: + name: opentelemetry-exporter-otlp-proto-grpc 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4229,10 +4208,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-zipkin-combined -- -ra + run: tox -e py312-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - py313-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined 3.13 Windows + py313-test-opentelemetry-exporter-otlp-proto-grpc_windows-latest: + name: opentelemetry-exporter-otlp-proto-grpc 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4250,31 +4229,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-zipkin-combined -- -ra - - pypy3-test-opentelemetry-exporter-zipkin-combined_windows-latest: - name: opentelemetry-exporter-zipkin-combined pypy-3.8 Windows - runs-on: windows-latest - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python pypy-3.8 - uses: actions/setup-python@v5 - with: - python-version: "pypy-3.8" - - - name: Install tox - run: pip install tox - - - name: Configure git to support long filenames - run: git config --system core.longpaths true - - - name: Run tests - run: tox -e pypy3-test-opentelemetry-exporter-zipkin-combined -- -ra + run: tox -e py313-test-opentelemetry-exporter-otlp-proto-grpc -- -ra - py38-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http 3.8 Windows + py38-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4292,10 +4250,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e py38-test-opentelemetry-exporter-otlp-proto-http -- -ra - py39-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http 3.9 Windows + py39-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4313,10 +4271,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e py39-test-opentelemetry-exporter-otlp-proto-http -- -ra - py310-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http 3.10 Windows + py310-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4334,10 +4292,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e py310-test-opentelemetry-exporter-otlp-proto-http -- -ra - py311-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http 3.11 Windows + py311-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4355,10 +4313,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e py311-test-opentelemetry-exporter-otlp-proto-http -- -ra - py312-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http 3.12 Windows + py312-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4376,10 +4334,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e py312-test-opentelemetry-exporter-otlp-proto-http -- -ra - py313-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http 3.13 Windows + py313-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4397,10 +4355,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e py313-test-opentelemetry-exporter-otlp-proto-http -- -ra - pypy3-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: - name: opentelemetry-exporter-zipkin-proto-http pypy-3.8 Windows + pypy3-test-opentelemetry-exporter-otlp-proto-http_windows-latest: + name: opentelemetry-exporter-otlp-proto-http pypy-3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4418,10 +4376,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e pypy3-test-opentelemetry-exporter-zipkin-proto-http -- -ra + run: tox -e pypy3-test-opentelemetry-exporter-otlp-proto-http -- -ra - py38-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json 3.8 Windows + py38-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4439,10 +4397,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e py38-test-opentelemetry-exporter-otlp-json-http -- -ra - py39-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json 3.9 Windows + py39-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4460,10 +4418,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e py39-test-opentelemetry-exporter-otlp-json-http -- -ra - py310-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json 3.10 Windows + py310-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4481,10 +4439,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e py310-test-opentelemetry-exporter-otlp-json-http -- -ra - py311-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json 3.11 Windows + py311-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4502,10 +4460,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e py311-test-opentelemetry-exporter-otlp-json-http -- -ra - py312-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json 3.12 Windows + py312-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4523,10 +4481,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e py312-test-opentelemetry-exporter-otlp-json-http -- -ra - py313-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json 3.13 Windows + py313-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4544,10 +4502,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e py313-test-opentelemetry-exporter-otlp-json-http -- -ra - pypy3-test-opentelemetry-exporter-zipkin-json_windows-latest: - name: opentelemetry-exporter-zipkin-json pypy-3.8 Windows + pypy3-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http pypy-3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4565,10 +4523,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e pypy3-test-opentelemetry-exporter-zipkin-json -- -ra + run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-http -- -ra - py38-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 3.8 Windows + py38-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4586,10 +4544,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-propagator-b3 -- -ra + run: tox -e py38-test-opentelemetry-exporter-prometheus -- -ra - py39-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 3.9 Windows + py39-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4607,10 +4565,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-propagator-b3 -- -ra + run: tox -e py39-test-opentelemetry-exporter-prometheus -- -ra - py310-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 3.10 Windows + py310-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4628,10 +4586,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-propagator-b3 -- -ra + run: tox -e py310-test-opentelemetry-exporter-prometheus -- -ra - py311-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 3.11 Windows + py311-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4649,10 +4607,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-propagator-b3 -- -ra + run: tox -e py311-test-opentelemetry-exporter-prometheus -- -ra - py312-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 3.12 Windows + py312-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4670,10 +4628,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-propagator-b3 -- -ra + run: tox -e py312-test-opentelemetry-exporter-prometheus -- -ra - py313-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 3.13 Windows + py313-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4691,10 +4649,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-propagator-b3 -- -ra + run: tox -e py313-test-opentelemetry-exporter-prometheus -- -ra - pypy3-test-opentelemetry-propagator-b3_windows-latest: - name: opentelemetry-propagator-b3 pypy-3.8 Windows + pypy3-test-opentelemetry-exporter-prometheus_windows-latest: + name: opentelemetry-exporter-prometheus pypy-3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4712,10 +4670,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e pypy3-test-opentelemetry-propagator-b3 -- -ra + run: tox -e pypy3-test-opentelemetry-exporter-prometheus -- -ra - py38-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger 3.8 Windows + py38-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4733,10 +4691,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e py38-test-opentelemetry-exporter-zipkin-combined -- -ra - py39-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger 3.9 Windows + py39-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined 3.9 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4754,10 +4712,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py39-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e py39-test-opentelemetry-exporter-zipkin-combined -- -ra - py310-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger 3.10 Windows + py310-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined 3.10 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4775,10 +4733,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py310-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e py310-test-opentelemetry-exporter-zipkin-combined -- -ra - py311-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger 3.11 Windows + py311-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined 3.11 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4796,10 +4754,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py311-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e py311-test-opentelemetry-exporter-zipkin-combined -- -ra - py312-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger 3.12 Windows + py312-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined 3.12 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4817,10 +4775,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py312-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e py312-test-opentelemetry-exporter-zipkin-combined -- -ra - py313-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger 3.13 Windows + py313-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined 3.13 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4838,10 +4796,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py313-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e py313-test-opentelemetry-exporter-zipkin-combined -- -ra - pypy3-test-opentelemetry-propagator-jaeger_windows-latest: - name: opentelemetry-propagator-jaeger pypy-3.8 Windows + pypy3-test-opentelemetry-exporter-zipkin-combined_windows-latest: + name: opentelemetry-exporter-zipkin-combined pypy-3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4859,10 +4817,10 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e pypy3-test-opentelemetry-propagator-jaeger -- -ra + run: tox -e pypy3-test-opentelemetry-exporter-zipkin-combined -- -ra - py38-test-opentelemetry-test-utils_windows-latest: - name: opentelemetry-test-utils 3.8 Windows + py38-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http 3.8 Windows runs-on: windows-latest steps: - name: Checkout repo @ SHA - ${{ github.sha }} @@ -4880,4 +4838,4 @@ jobs: run: git config --system core.longpaths true - name: Run tests - run: tox -e py38-test-opentelemetry-test-utils -- -ra + run: tox -e py38-test-opentelemetry-exporter-zipkin-proto-http -- -ra diff --git a/.github/workflows/test_1.yml b/.github/workflows/test_1.yml index 49d34716c58..521f0bb04e7 100644 --- a/.github/workflows/test_1.yml +++ b/.github/workflows/test_1.yml @@ -16,6 +16,594 @@ env: jobs: + py39-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http 3.9 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py39-test-opentelemetry-exporter-zipkin-proto-http -- -ra + + py310-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http 3.10 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py310-test-opentelemetry-exporter-zipkin-proto-http -- -ra + + py311-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http 3.11 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py311-test-opentelemetry-exporter-zipkin-proto-http -- -ra + + py312-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http 3.12 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py312-test-opentelemetry-exporter-zipkin-proto-http -- -ra + + py313-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http 3.13 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py313-test-opentelemetry-exporter-zipkin-proto-http -- -ra + + pypy3-test-opentelemetry-exporter-zipkin-proto-http_windows-latest: + name: opentelemetry-exporter-zipkin-proto-http pypy-3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.8 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-exporter-zipkin-proto-http -- -ra + + py38-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json 3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py38-test-opentelemetry-exporter-zipkin-json -- -ra + + py39-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json 3.9 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py39-test-opentelemetry-exporter-zipkin-json -- -ra + + py310-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json 3.10 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py310-test-opentelemetry-exporter-zipkin-json -- -ra + + py311-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json 3.11 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py311-test-opentelemetry-exporter-zipkin-json -- -ra + + py312-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json 3.12 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py312-test-opentelemetry-exporter-zipkin-json -- -ra + + py313-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json 3.13 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py313-test-opentelemetry-exporter-zipkin-json -- -ra + + pypy3-test-opentelemetry-exporter-zipkin-json_windows-latest: + name: opentelemetry-exporter-zipkin-json pypy-3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.8 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-exporter-zipkin-json -- -ra + + py38-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py38-test-opentelemetry-propagator-b3 -- -ra + + py39-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 3.9 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py39-test-opentelemetry-propagator-b3 -- -ra + + py310-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 3.10 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py310-test-opentelemetry-propagator-b3 -- -ra + + py311-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 3.11 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py311-test-opentelemetry-propagator-b3 -- -ra + + py312-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 3.12 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py312-test-opentelemetry-propagator-b3 -- -ra + + py313-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 3.13 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py313-test-opentelemetry-propagator-b3 -- -ra + + pypy3-test-opentelemetry-propagator-b3_windows-latest: + name: opentelemetry-propagator-b3 pypy-3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.8 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-propagator-b3 -- -ra + + py38-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger 3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py38-test-opentelemetry-propagator-jaeger -- -ra + + py39-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger 3.9 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py39-test-opentelemetry-propagator-jaeger -- -ra + + py310-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger 3.10 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py310-test-opentelemetry-propagator-jaeger -- -ra + + py311-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger 3.11 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py311-test-opentelemetry-propagator-jaeger -- -ra + + py312-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger 3.12 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py312-test-opentelemetry-propagator-jaeger -- -ra + + py313-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger 3.13 Windows + runs-on: windows-latest + 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 + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py313-test-opentelemetry-propagator-jaeger -- -ra + + pypy3-test-opentelemetry-propagator-jaeger_windows-latest: + name: opentelemetry-propagator-jaeger pypy-3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.8 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-propagator-jaeger -- -ra + + py38-test-opentelemetry-test-utils_windows-latest: + name: opentelemetry-test-utils 3.8 Windows + runs-on: windows-latest + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install tox + run: pip install tox + + - name: Configure git to support long filenames + run: git config --system core.longpaths true + + - name: Run tests + run: tox -e py38-test-opentelemetry-test-utils -- -ra + py39-test-opentelemetry-test-utils_windows-latest: name: opentelemetry-test-utils 3.9 Windows runs-on: windows-latest diff --git a/exporter/opentelemetry-exporter-otlp-json-common/LICENSE b/exporter/opentelemetry-exporter-otlp-json-common/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/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/exporter/opentelemetry-exporter-otlp-json-common/README.rst b/exporter/opentelemetry-exporter-otlp-json-common/README.rst new file mode 100644 index 00000000000..732c1198e0c --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/README.rst @@ -0,0 +1,28 @@ +OpenTelemetry JSON Encoding +=========================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-exporter-otlp-json-common.svg + :target: https://pypi.org/project/opentelemetry-exporter-otlp-json-common/ + +This library is provided as a convenience to encode to JSON format for OTLP. Currently used by: + +* opentelemetry-exporter-otlp-json-http +* (Future) opentelemetry-exporter-otlp-json-grpc + +This package provides JSON encoding for OpenTelemetry's traces, metrics, and logs, which is required by some collectors and observability platforms like Langfuse. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-otlp-json-common + + +References +---------- + +* `OpenTelemetry `_ +* `OpenTelemetry Protocol Specification `_ \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-common/pyproject.toml b/exporter/opentelemetry-exporter-otlp-json-common/pyproject.toml new file mode 100644 index 00000000000..f41dfe38c6a --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-exporter-otlp-json-common" +dynamic = ["version"] +description = "OpenTelemetry JSON encoding" +readme = "README.rst" +license = {text = "Apache-2.0"} +requires-python = ">=3.8" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: OpenTelemetry", + "Framework :: OpenTelemetry :: Exporters", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api", + "opentelemetry-sdk", +] + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/exporter/opentelemetry-exporter-otlp-json-common" +Repository = "https://github.com/open-telemetry/opentelemetry-python" + +[tool.hatch.version] +path = "src/opentelemetry/exporter/otlp/json/common/version/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py new file mode 100644 index 00000000000..67a4834a903 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/__init__.py @@ -0,0 +1,18 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from opentelemetry.exporter.otlp.json.common.version import __version__ + +__all__ = ["__version__"] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py new file mode 100644 index 00000000000..07b55c367e8 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py @@ -0,0 +1,269 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import base64 +import logging +from collections.abc import Sequence +from typing import ( + Any, + Callable, + Dict, + Generator, + List, + Mapping, + Optional, + TypeVar, +) + +from opentelemetry.sdk.trace import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.util.types import Attributes + +_logger = logging.getLogger(__name__) + +_TypingResourceT = TypeVar("_TypingResourceT") +_ResourceDataT = TypeVar("_ResourceDataT") + + +def _encode_instrumentation_scope( + instrumentation_scope: InstrumentationScope, +) -> Dict[str, Any]: + """ + Encodes an InstrumentationScope object to a JSON-serializable dict. + + Args: + instrumentation_scope: The instrumentation scope to encode + + Returns: + A dict representing the instrumentation scope + """ + if instrumentation_scope is None: + return {} + + scope_dict = { + "name": instrumentation_scope.name, + } + + if instrumentation_scope.version: + scope_dict["version"] = instrumentation_scope.version + + if instrumentation_scope.attributes: + scope_dict["attributes"] = _encode_attributes( + instrumentation_scope.attributes + ) + + return scope_dict + + +def _encode_resource(resource: Resource) -> Dict[str, Any]: + """ + Encodes a Resource object to a JSON-serializable dict. + + Args: + resource: The resource to encode + + Returns: + A dict representing the resource + """ + if resource is None or not resource.attributes: + return {} + + return {"attributes": _encode_attributes(resource.attributes)} + + +def _encode_value(value: Any, allow_null: bool = False) -> Optional[Any]: + """ + Encodes a value for use in OTLP JSON format. + + Args: + value: The value to encode. + allow_null: Whether to allow null values. + + Returns: + The encoded value. + """ + if allow_null is True and value is None: + return None + if isinstance(value, (bool, str, int, float)): + return value + if isinstance(value, bytes): + # Convert bytes to base64 string for JSON + return {"bytes_value": base64.b64encode(value).decode("ascii")} + if isinstance(value, Sequence): + return _encode_array(value, allow_null=allow_null) + if isinstance(value, Mapping): + return { + "kvlist_value": { + str(k): _encode_value(v, allow_null=allow_null) + for k, v in value.items() + } + } + + raise ValueError(f"Invalid type {type(value)} of value {value}") + + +def _encode_key_value( + key: str, value: Any, allow_null: bool = False +) -> Dict[str, Any]: + """ + Encodes a key-value pair to a JSON-serializable dict. + + Args: + key: The key + value: The value + allow_null: Whether null values are allowed + + Returns: + A dict representing the key-value pair + """ + return {key: _encode_value(value, allow_null=allow_null)} + + +def _encode_array(array: Sequence[Any], allow_null: bool = False) -> List[Any]: + """ + Encodes an array to a JSON-serializable list. + + Args: + array: The array to encode + allow_null: Whether null values are allowed + + Returns: + A list of encoded values + """ + if not allow_null: + return [_encode_value(v, allow_null=allow_null) for v in array] + + return [ + _encode_value(v, allow_null=allow_null) if v is not None else None + for v in array + ] + + +def _encode_span_id(span_id: int) -> str: + """ + Encodes a span ID to a hexadecimal string. + + Args: + span_id: The span ID as an integer + + Returns: + The span ID as a 16-character hexadecimal string + """ + return f"{span_id:016x}" + + +def _encode_trace_id(trace_id: int) -> str: + """ + Encodes a trace ID to a hexadecimal string. + + Args: + trace_id: The trace ID as an integer + + Returns: + The trace ID as a 32-character hexadecimal string + """ + return f"{trace_id:032x}" + + +def _encode_attributes( + attributes: Attributes, +) -> Optional[Dict[str, Any]]: + """ + Encodes attributes to a JSON-serializable dict. + + Args: + attributes: The attributes to encode + + Returns: + A dict of encoded attributes, or None if there are no attributes + """ + if not attributes: + return None + + encoded_attributes = {} + for key, value in attributes.items(): + # pylint: disable=broad-exception-caught + try: + encoded_value = _encode_value(value) + encoded_attributes[key] = encoded_value + except Exception as error: + _logger.exception("Failed to encode key %s: %s", key, error) + + return encoded_attributes if encoded_attributes else None + + +def _get_resource_data( + sdk_resource_scope_data: Dict[Resource, _ResourceDataT], + resource_class: Callable[..., _TypingResourceT], + name: str, +) -> List[_TypingResourceT]: + """ + Transforms SDK resource scope data into resource data for JSON format. + + Args: + sdk_resource_scope_data: The SDK resource scope data + resource_class: A function to create a resource class instance + name: The name of the scope + + Returns: + A list of resource class instances + """ + resource_data = [] + + for ( + sdk_resource, + scope_data, + ) in sdk_resource_scope_data.items(): + json_resource = _encode_resource(sdk_resource) + resource_data.append( + resource_class( + **{ + "resource": json_resource, + f"scope_{name}": list(scope_data.values()), + } + ) + ) + return resource_data + + +def _create_exp_backoff_generator( + init_value: float = 1, max_value: float = float("inf") +) -> Generator[float, None, None]: + """Generator for exponential backoff with random jitter. + + Args: + init_value: initial backoff value in seconds + max_value: maximum backoff value in seconds + + Returns: + A generator that yields a random backoff value between 0 and + min(init_value * 2 ** n, max_value) where n is the number of + times the generator has been called so far. + + Example: + >>> gen = _create_exp_backoff_generator(1, 10) + >>> next(gen) # Random value between 0 and 1 + >>> next(gen) # Random value between 0 and 2 + >>> next(gen) # Random value between 0 and 4 + >>> next(gen) # Random value between 0 and 8 + >>> next(gen) # Random value between 0 and 10 + >>> next(gen) # Random value between 0 and 10 + """ + curr = init_value + while True: + yield curr + curr = min(curr * 2, max_value) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py new file mode 100644 index 00000000000..c96d26eefa7 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py @@ -0,0 +1,269 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""JSON encoder for OpenTelemetry logs to match the ProtoJSON format.""" + +import base64 +from typing import Any, Dict, List, Optional, Sequence + +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk._logs import LogData +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + + +def encode_logs(logs_data: Sequence[LogData]) -> Dict[str, Any]: + """Encodes logs in the OTLP JSON format. + + Returns: + A dict representing the logs in OTLP JSON format as specified in the + OpenTelemetry Protocol and ProtoJSON format. + """ + # Group logs by resource + resource_logs = {} + for log_data in logs_data: + resource_key = _compute_resource_hashcode(log_data.log_record.resource) + + if resource_key not in resource_logs: + resource_logs[resource_key] = { + "resource": _encode_resource(log_data.log_record.resource), + "scopeLogs": {}, + "schemaUrl": getattr( + log_data.log_record.resource, "schema_url", "" + ), + } + + # Group logs by instrumentation scope within each resource + scope_key = _compute_instrumentation_scope_hashcode( + log_data.instrumentation_scope + ) + scope_logs = resource_logs[resource_key]["scopeLogs"] + + if scope_key not in scope_logs: + scope_logs[scope_key] = { + "scope": _encode_instrumentation_scope( + log_data.instrumentation_scope + ), + "logRecords": [], + "schemaUrl": ( + getattr(log_data.instrumentation_scope, "schema_url", "") + if log_data.instrumentation_scope + else "" + ), + } + + # Add log record to the appropriate scope + scope_logs[scope_key]["logRecords"].append( + _encode_log_record(log_data) + ) + + # Convert dictionaries to lists for JSON output + resource_logs_list = [] + for resource_log_data in resource_logs.values(): + scope_logs_list = [] + for scope_log_data in resource_log_data["scopeLogs"].values(): + scope_logs_list.append(scope_log_data) + + resource_log_data["scopeLogs"] = scope_logs_list + resource_logs_list.append(resource_log_data) + + return {"resourceLogs": resource_logs_list} + + +def _compute_resource_hashcode(resource: Resource) -> str: + """Computes a hashcode for the resource based on its attributes.""" + if not resource or not resource.attributes: + return "" + # Simple implementation: use string representation of sorted attributes + return str(sorted(resource.attributes.items())) + + +def _compute_instrumentation_scope_hashcode( + scope: Optional[InstrumentationScope], +) -> str: + """Computes a hashcode for the instrumentation scope.""" + if scope is None: + return "" + return f"{scope.name}|{scope.version}" + + +def _encode_resource(resource: Resource) -> Dict[str, Any]: + """Encodes a resource into OTLP JSON format.""" + if not resource: + return {"attributes": []} + + return { + "attributes": _encode_attributes(resource.attributes), + "droppedAttributesCount": 0, # Not tracking dropped attributes yet + } + + +def _encode_instrumentation_scope( + scope: Optional[InstrumentationScope], +) -> Dict[str, Any]: + """Encodes an instrumentation scope into OTLP JSON format.""" + if scope is None: + return {"name": "", "version": ""} + + return { + "name": scope.name or "", + "version": scope.version or "", + "attributes": [], # Not using attributes for scope yet + "droppedAttributesCount": 0, + } + + +def _encode_log_record(log_data: LogData) -> Dict[str, Any]: + """Encodes a log record into OTLP JSON format.""" + log_record = log_data.log_record + + result = { + "timeUnixNano": str(log_record.timestamp), + "observedTimeUnixNano": str( + getattr(log_record, "observed_timestamp", log_record.timestamp) + ), + "severityNumber": _get_severity_number_value( + log_record.severity_number + ), + "severityText": log_record.severity_text or "", + "attributes": _encode_attributes(log_record.attributes), + "droppedAttributesCount": getattr(log_record, "dropped_attributes", 0), + } + + # Handle body based on type + if log_record.body is not None: + result.update(_encode_any_value(log_record.body)) + + # Handle trace context if present + if log_record.trace_id: + trace_id_bytes = log_record.trace_id.to_bytes(16, "big") + result["traceId"] = base64.b64encode(trace_id_bytes).decode("ascii") + + if log_record.span_id: + span_id_bytes = log_record.span_id.to_bytes(8, "big") + result["spanId"] = base64.b64encode(span_id_bytes).decode("ascii") + + if ( + hasattr(log_record, "trace_flags") + and log_record.trace_flags is not None + ): + result["flags"] = int(log_record.trace_flags) + + return result + + +def _encode_attributes(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: + """Encodes attributes into OTLP JSON format.""" + if not attributes: + return [] + + attribute_list = [] + for key, value in attributes.items(): + if value is None: + continue + + attribute = {"key": key} + attribute.update(_encode_attribute_value(value)) + attribute_list.append(attribute) + + return attribute_list + + +# pylint: disable=too-many-return-statements +def _encode_attribute_value(value: Any) -> Dict[str, Any]: + """Encodes a single attribute value into OTLP JSON format.""" + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + return {"doubleValue": value} + if isinstance(value, str): + return {"stringValue": value} + if isinstance(value, (list, tuple)): + if not value: + return {"arrayValue": {"values": []}} + + array_value = {"values": []} + for element in value: + element_value = _encode_attribute_value(element) + array_value["values"].append(element_value) + + return {"arrayValue": array_value} + if isinstance(value, bytes): + return {"bytesValue": base64.b64encode(value).decode("ascii")} + # Convert anything else to string + return {"stringValue": str(value)} + + +# pylint: disable=too-many-return-statements +def _encode_any_value(value: Any) -> Dict[str, Any]: + """Encodes any log record body value into OTLP JSON format.""" + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + return {"doubleValue": value} + if isinstance(value, str): + return {"stringValue": value} + if isinstance(value, (list, tuple)): + values = [] + for element in value: + values.append(_encode_any_value(element)) + return {"arrayValue": {"values": values}} + if isinstance(value, dict): + kvlist = [] + for key, val in value.items(): + if val is not None: + kv = {"key": str(key)} + kv.update(_encode_any_value(val)) + kvlist.append(kv) + return {"kvlistValue": {"values": kvlist}} + if isinstance(value, bytes): + return {"bytesValue": base64.b64encode(value).decode("ascii")} + # Convert anything else to string + return {"stringValue": str(value)} + + +def _get_severity_number_value(severity_number: SeverityNumber) -> str: + """Converts a SeverityNumber enum to its string representation for ProtoJSON format.""" + severity_map = { + SeverityNumber.UNSPECIFIED: "SEVERITY_NUMBER_UNSPECIFIED", + SeverityNumber.TRACE: "SEVERITY_NUMBER_TRACE", + SeverityNumber.TRACE2: "SEVERITY_NUMBER_TRACE2", + SeverityNumber.TRACE3: "SEVERITY_NUMBER_TRACE3", + SeverityNumber.TRACE4: "SEVERITY_NUMBER_TRACE4", + SeverityNumber.DEBUG: "SEVERITY_NUMBER_DEBUG", + SeverityNumber.DEBUG2: "SEVERITY_NUMBER_DEBUG2", + SeverityNumber.DEBUG3: "SEVERITY_NUMBER_DEBUG3", + SeverityNumber.DEBUG4: "SEVERITY_NUMBER_DEBUG4", + SeverityNumber.INFO: "SEVERITY_NUMBER_INFO", + SeverityNumber.INFO2: "SEVERITY_NUMBER_INFO2", + SeverityNumber.INFO3: "SEVERITY_NUMBER_INFO3", + SeverityNumber.INFO4: "SEVERITY_NUMBER_INFO4", + SeverityNumber.WARN: "SEVERITY_NUMBER_WARN", + SeverityNumber.WARN2: "SEVERITY_NUMBER_WARN2", + SeverityNumber.WARN3: "SEVERITY_NUMBER_WARN3", + SeverityNumber.WARN4: "SEVERITY_NUMBER_WARN4", + SeverityNumber.ERROR: "SEVERITY_NUMBER_ERROR", + SeverityNumber.ERROR2: "SEVERITY_NUMBER_ERROR2", + SeverityNumber.ERROR3: "SEVERITY_NUMBER_ERROR3", + SeverityNumber.ERROR4: "SEVERITY_NUMBER_ERROR4", + SeverityNumber.FATAL: "SEVERITY_NUMBER_FATAL", + SeverityNumber.FATAL2: "SEVERITY_NUMBER_FATAL2", + SeverityNumber.FATAL3: "SEVERITY_NUMBER_FATAL3", + SeverityNumber.FATAL4: "SEVERITY_NUMBER_FATAL4", + } + return severity_map.get(severity_number, "SEVERITY_NUMBER_UNSPECIFIED") diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/encoder_utils.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/encoder_utils.py new file mode 100644 index 00000000000..b0a6f428417 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/encoder_utils.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py new file mode 100644 index 00000000000..08243804b8c --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py @@ -0,0 +1,498 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""JSON encoder for OpenTelemetry metrics to match the ProtoJSON format.""" + +import base64 +import logging +from os import environ +from typing import Any, Dict, List, Optional, Sequence + +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION, + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE, +) +from opentelemetry.sdk.metrics import ( + Counter, + Histogram, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + ExponentialHistogram, + Gauge, + Metric, + MetricExporter, + MetricsData, + ScopeMetrics, + Sum, +) +from opentelemetry.sdk.metrics.export import ( + Histogram as HistogramType, +) +from opentelemetry.sdk.metrics.view import ( + Aggregation, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + +_logger = logging.getLogger(__name__) + + +class OTLPMetricExporterMixin: + def _common_configuration( + self, + preferred_temporality: Optional[ + Dict[type, AggregationTemporality] + ] = None, + preferred_aggregation: Optional[Dict[type, Aggregation]] = None, + ) -> None: + MetricExporter.__init__( + self, + preferred_temporality=self._get_temporality(preferred_temporality), + preferred_aggregation=self._get_aggregation(preferred_aggregation), + ) + + @staticmethod + def _get_temporality( + preferred_temporality: Dict[type, AggregationTemporality], + ) -> Dict[type, AggregationTemporality]: + otel_exporter_otlp_metrics_temporality_preference = ( + environ.get( + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE, + "CUMULATIVE", + ) + .upper() + .strip() + ) + + if otel_exporter_otlp_metrics_temporality_preference == "DELTA": + instrument_class_temporality = { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.DELTA, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + + elif otel_exporter_otlp_metrics_temporality_preference == "LOWMEMORY": + instrument_class_temporality = { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + + else: + if otel_exporter_otlp_metrics_temporality_preference != ( + "CUMULATIVE" + ): + _logger.warning( + "Unrecognized OTEL_EXPORTER_METRICS_TEMPORALITY_PREFERENCE" + " value found: " + "%s, " + "using CUMULATIVE", + otel_exporter_otlp_metrics_temporality_preference, + ) + instrument_class_temporality = { + Counter: AggregationTemporality.CUMULATIVE, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.CUMULATIVE, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + + instrument_class_temporality.update(preferred_temporality or {}) + + return instrument_class_temporality + + @staticmethod + def _get_aggregation( + preferred_aggregation: Dict[type, Aggregation], + ) -> Dict[type, Aggregation]: + otel_exporter_otlp_metrics_default_histogram_aggregation = environ.get( + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION, + "explicit_bucket_histogram", + ) + + if otel_exporter_otlp_metrics_default_histogram_aggregation == ( + "base2_exponential_bucket_histogram" + ): + instrument_class_aggregation = { + Histogram: ExponentialBucketHistogramAggregation(), + } + + else: + if otel_exporter_otlp_metrics_default_histogram_aggregation != ( + "explicit_bucket_histogram" + ): + _logger.warning( + ( + "Invalid value for %s: %s, using explicit bucket " + "histogram aggregation" + ), + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION, + otel_exporter_otlp_metrics_default_histogram_aggregation, + ) + + instrument_class_aggregation = { + Histogram: ExplicitBucketHistogramAggregation(), + } + + instrument_class_aggregation.update(preferred_aggregation or {}) + + return instrument_class_aggregation + + +def encode_metrics(metrics_data: MetricsData) -> Dict[str, Any]: + """Encodes metrics in the OTLP JSON format. + + Returns: + A dict representing the metrics in OTLP JSON format as specified in the + OpenTelemetry Protocol and ProtoJSON format. + """ + resource_metrics_list = [] + + for resource_metrics in metrics_data.resource_metrics: + resource_metrics_dict = { + "resource": _encode_resource(resource_metrics.resource), + "scopeMetrics": _encode_scope_metrics( + resource_metrics.scope_metrics + ), + "schemaUrl": resource_metrics.schema_url or "", + } + resource_metrics_list.append(resource_metrics_dict) + + return {"resourceMetrics": resource_metrics_list} + + +def _encode_resource(resource: Resource) -> Dict[str, Any]: + """Encodes a resource into OTLP JSON format.""" + if not resource: + return {"attributes": []} + + return { + "attributes": _encode_attributes(resource.attributes), + "droppedAttributesCount": 0, # Not tracking dropped attributes yet + } + + +def _encode_scope_metrics( + scope_metrics_list: Sequence[ScopeMetrics], +) -> List[Dict[str, Any]]: + """Encodes a list of scope metrics into OTLP JSON format.""" + if not scope_metrics_list: + return [] + + result = [] + for scope_metrics in scope_metrics_list: + result.append( + { + "scope": _encode_instrumentation_scope(scope_metrics.scope), + "metrics": _encode_metrics_list(scope_metrics.metrics), + "schemaUrl": scope_metrics.schema_url or "", + } + ) + + return result + + +def _encode_instrumentation_scope( + scope: Optional[InstrumentationScope], +) -> Dict[str, Any]: + """Encodes an instrumentation scope into OTLP JSON format.""" + if scope is None: + return {"name": "", "version": ""} + + return { + "name": scope.name or "", + "version": scope.version or "", + "attributes": [], # Not using attributes for scope yet + "droppedAttributesCount": 0, + } + + +def _encode_metrics_list(metrics: Sequence[Metric]) -> List[Dict[str, Any]]: + """Encodes a list of metrics into OTLP JSON format.""" + if not metrics: + return [] + + result = [] + for metric in metrics: + metric_dict = { + "name": metric.name, + "description": metric.description or "", + "unit": metric.unit or "", + } + + # Add data based on metric type + if isinstance(metric.data, Sum): + metric_dict["sum"] = _encode_sum(metric.data) + elif isinstance(metric.data, Gauge): + metric_dict["gauge"] = _encode_gauge(metric.data) + elif isinstance(metric.data, HistogramType): + metric_dict["histogram"] = _encode_histogram(metric.data) + elif isinstance(metric.data, ExponentialHistogram): + metric_dict["exponentialHistogram"] = ( + _encode_exponential_histogram(metric.data) + ) + # Add other metric types as needed + + result.append(metric_dict) + + return result + + +def _encode_sum(sum_data: Sum) -> Dict[str, Any]: + """Encodes a Sum metric into OTLP JSON format.""" + result = { + "dataPoints": _encode_number_data_points(sum_data.data_points), + "aggregationTemporality": _get_aggregation_temporality( + sum_data.aggregation_temporality + ), + "isMonotonic": sum_data.is_monotonic, + } + + return result + + +def _encode_gauge(gauge_data: Gauge) -> Dict[str, Any]: + """Encodes a Gauge metric into OTLP JSON format.""" + return { + "dataPoints": _encode_number_data_points(gauge_data.data_points), + } + + +def _encode_histogram(histogram_data: HistogramType) -> Dict[str, Any]: + """Encodes a Histogram metric into OTLP JSON format.""" + data_points = [] + + for point in histogram_data.data_points: + point_dict = { + "attributes": _encode_attributes(point.attributes), + "startTimeUnixNano": str(point.start_time_unix_nano), + "timeUnixNano": str(point.time_unix_nano), + "count": str(point.count), + "sum": point.sum if point.sum is not None else 0.0, + "bucketCounts": [str(count) for count in point.bucket_counts], + "explicitBounds": point.explicit_bounds, + } + + # Add min/max if available + if point.min is not None: + point_dict["min"] = point.min + + if point.max is not None: + point_dict["max"] = point.max + + # Optional exemplars field + if hasattr(point, "exemplars") and point.exemplars: + point_dict["exemplars"] = _encode_exemplars(point.exemplars) + + data_points.append(point_dict) + + return { + "dataPoints": data_points, + "aggregationTemporality": _get_aggregation_temporality( + histogram_data.aggregation_temporality + ), + } + + +def _encode_exponential_histogram( + histogram_data: ExponentialHistogram, +) -> Dict[str, Any]: + """Encodes an ExponentialHistogram metric into OTLP JSON format.""" + data_points = [] + + for point in histogram_data.data_points: + point_dict = { + "attributes": _encode_attributes(point.attributes), + "startTimeUnixNano": str(point.start_time_unix_nano), + "timeUnixNano": str(point.time_unix_nano), + "count": str(point.count), + "sum": point.sum if point.sum is not None else 0.0, + "scale": point.scale, + "zeroCount": str(point.zero_count), + } + + # Add positive buckets if available + if point.positive and point.positive.bucket_counts: + point_dict["positive"] = { + "offset": point.positive.offset, + "bucketCounts": [ + str(count) for count in point.positive.bucket_counts + ], + } + + # Add negative buckets if available + if point.negative and point.negative.bucket_counts: + point_dict["negative"] = { + "offset": point.negative.offset, + "bucketCounts": [ + str(count) for count in point.negative.bucket_counts + ], + } + + # Add min/max if available + if point.min is not None: + point_dict["min"] = point.min + + if point.max is not None: + point_dict["max"] = point.max + + # Add flags if available + if point.flags: + point_dict["flags"] = point.flags + + # Add exemplars if available + if hasattr(point, "exemplars") and point.exemplars: + point_dict["exemplars"] = _encode_exemplars(point.exemplars) + + data_points.append(point_dict) + + return { + "dataPoints": data_points, + "aggregationTemporality": _get_aggregation_temporality( + histogram_data.aggregation_temporality + ), + } + + +def _encode_number_data_points( + data_points: Sequence[Any], +) -> List[Dict[str, Any]]: + """Encodes number data points into OTLP JSON format.""" + result = [] + + for point in data_points: + point_dict = { + "attributes": _encode_attributes(point.attributes), + "startTimeUnixNano": str(point.start_time_unix_nano), + "timeUnixNano": str(point.time_unix_nano), + } + + # Add either int or double value based on point type + if hasattr(point, "value") and isinstance(point.value, int): + point_dict["asInt"] = str( + point.value + ) # int64 values as strings in JSON + elif hasattr(point, "value"): + point_dict["asDouble"] = float(point.value) + + # Optional exemplars field + if hasattr(point, "exemplars") and point.exemplars: + point_dict["exemplars"] = _encode_exemplars(point.exemplars) + + result.append(point_dict) + + return result + + +def _encode_exemplars(exemplars: Sequence[Any]) -> List[Dict[str, Any]]: + """Encodes metric exemplars into OTLP JSON format.""" + result = [] + + for exemplar in exemplars: + exemplar_dict = { + "filteredAttributes": _encode_attributes( + exemplar.filtered_attributes + ), + "timeUnixNano": str(exemplar.time_unix_nano), + } + + # Add trace info if available + if hasattr(exemplar, "trace_id") and exemplar.trace_id: + trace_id_bytes = exemplar.trace_id.to_bytes(16, "big") + exemplar_dict["traceId"] = base64.b64encode(trace_id_bytes).decode( + "ascii" + ) + + if hasattr(exemplar, "span_id") and exemplar.span_id: + span_id_bytes = exemplar.span_id.to_bytes(8, "big") + exemplar_dict["spanId"] = base64.b64encode(span_id_bytes).decode( + "ascii" + ) + + # Add value based on type + if hasattr(exemplar, "value") and isinstance(exemplar.value, int): + exemplar_dict["asInt"] = str(exemplar.value) + elif hasattr(exemplar, "value") and isinstance(exemplar.value, float): + exemplar_dict["asDouble"] = exemplar.value + + result.append(exemplar_dict) + + return result + + +def _encode_attributes(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: + """Encodes attributes into OTLP JSON format.""" + if not attributes: + return [] + + attribute_list = [] + for key, value in attributes.items(): + if value is None: + continue + + attribute = {"key": key} + attribute.update(_encode_attribute_value(value)) + attribute_list.append(attribute) + + return attribute_list + + +# pylint: disable=too-many-return-statements +def _encode_attribute_value(value: Any) -> Dict[str, Any]: + """Encodes a single attribute value into OTLP JSON format.""" + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + return {"doubleValue": value} + if isinstance(value, str): + return {"stringValue": value} + if isinstance(value, (list, tuple)): + if not value: + return {"arrayValue": {"values": []}} + + array_value = {"values": []} + for element in value: + element_value = _encode_attribute_value(element) + array_value["values"].append(element_value) + + return {"arrayValue": array_value} + if isinstance(value, bytes): + return {"bytesValue": base64.b64encode(value).decode("ascii")} + # Convert anything else to string + return {"stringValue": str(value)} + + +def _get_aggregation_temporality(temporality) -> str: + """Maps aggregation temporality to OTLP JSON string values.""" + if temporality == 1: # DELTA + return "AGGREGATION_TEMPORALITY_DELTA" + if temporality == 2: # CUMULATIVE + return "AGGREGATION_TEMPORALITY_CUMULATIVE" + return "AGGREGATION_TEMPORALITY_UNSPECIFIED" diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py new file mode 100644 index 00000000000..9df9f170a4a --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py @@ -0,0 +1,346 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""JSON encoder for OpenTelemetry spans to match the ProtoJSON format.""" + +import base64 +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +from opentelemetry import trace +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import Event, ReadableSpan, Status, StatusCode +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + + +def encode_spans(spans: Sequence[ReadableSpan]) -> Dict[str, Any]: + """Encodes spans in the OTLP JSON format. + + Returns: + A dict representing the spans in OTLP JSON format as specified in the + OpenTelemetry Protocol and ProtoJSON format. + """ + resource_spans = {} # Key is resource hashcode + for span in spans: + if span.resource.attributes or not resource_spans: + resource_key = _compute_resource_hashcode(span.resource) + if resource_key not in resource_spans: + resource_spans[resource_key] = { + "resource": _encode_resource(span.resource), + "scopeSpans": {}, # Key is instrumentation scope hashcode + "schemaUrl": span.resource.schema_url or "", + } + else: + # Handle empty resource + resource_key = "" + if resource_key not in resource_spans: + resource_spans[resource_key] = { + "resource": _encode_resource(span.resource), + "scopeSpans": {}, + "schemaUrl": "", + } + + instrumentation_scope_hashcode = ( + _compute_instrumentation_scope_hashcode(span.instrumentation_scope) + ) + scope_spans = resource_spans[resource_key]["scopeSpans"] + + if instrumentation_scope_hashcode not in scope_spans: + scope_spans[instrumentation_scope_hashcode] = { + "scope": _encode_instrumentation_scope( + span.instrumentation_scope + ), + "spans": [], + "schemaUrl": ( + span.instrumentation_scope.schema_url + if hasattr(span.instrumentation_scope, "schema_url") + else "" + ), + } + + scope_spans[instrumentation_scope_hashcode]["spans"].append( + _encode_span(span) + ) + + # Transform resource_spans dict to list for proper JSON output + resource_spans_list = [] + for resource_span_data in resource_spans.values(): + scope_spans_list = [] + for scope_span_data in resource_span_data["scopeSpans"].values(): + scope_spans_list.append(scope_span_data) + + resource_span_data["scopeSpans"] = scope_spans_list + resource_spans_list.append(resource_span_data) + + return {"resourceSpans": resource_spans_list} + + +def _compute_resource_hashcode(resource: Resource) -> str: + """Computes a hashcode for the resource based on its attributes.""" + if not resource.attributes: + return "" + # Simple implementation: use string representation of sorted attributes + return str(sorted(resource.attributes.items())) + + +def _compute_instrumentation_scope_hashcode( + scope: InstrumentationScope, +) -> str: + """Computes a hashcode for the instrumentation scope.""" + if scope is None: + return "" + return f"{scope.name}|{scope.version}" + + +def _encode_resource(resource: Resource) -> Dict[str, Any]: + """Encodes a resource into OTLP JSON format.""" + if not resource: + return {"attributes": []} + + return { + "attributes": _encode_attributes(resource.attributes), + "droppedAttributesCount": 0, # Not tracking dropped attributes yet + } + + +def _encode_instrumentation_scope( + scope: Optional[InstrumentationScope], +) -> Dict[str, Any]: + """Encodes an instrumentation scope into OTLP JSON format.""" + if scope is None: + return {"name": "", "version": ""} + + return { + "name": scope.name or "", + "version": scope.version or "", + "attributes": [], # Not using attributes for scope yet + "droppedAttributesCount": 0, + } + + +def _encode_span(span: ReadableSpan) -> Dict[str, Any]: + """Encodes a span into OTLP JSON format.""" + # Convert trace_id and span_id to base64 + trace_id_bytes = span.context.trace_id.to_bytes(16, "big") + span_id_bytes = span.context.span_id.to_bytes(8, "big") + + parent_id = "" + # Handle different span implementations that might not have parent_span_id + if hasattr(span, "parent_span_id") and span.parent_span_id: + parent_id = base64.b64encode( + span.parent_span_id.to_bytes(8, "big") + ).decode("ascii") + elif ( + hasattr(span, "parent") + and span.parent + and hasattr(span.parent, "span_id") + ): + parent_id = base64.b64encode( + span.parent.span_id.to_bytes(8, "big") + ).decode("ascii") + + # Convert timestamps to nanoseconds + start_time_ns = _timestamp_to_ns(span.start_time) + end_time_ns = _timestamp_to_ns(span.end_time) if span.end_time else 0 + + # Format span according to ProtoJSON + result = { + "traceId": base64.b64encode(trace_id_bytes).decode("ascii"), + "spanId": base64.b64encode(span_id_bytes).decode("ascii"), + "parentSpanId": parent_id, + "name": span.name, + "kind": _get_span_kind_value(span.kind), + "startTimeUnixNano": str(start_time_ns), + "endTimeUnixNano": str(end_time_ns), + "attributes": _encode_attributes(span.attributes), + "droppedAttributesCount": span.dropped_attributes, + "events": _encode_events(span.events), + "droppedEventsCount": span.dropped_events, + "links": _encode_links(span.links), + "droppedLinksCount": span.dropped_links, + "status": _encode_status(span.status), + } + + # Add traceState if it exists + if span.context.trace_state: + result["traceState"] = str(span.context.trace_state) + + return result + + +def _encode_attributes(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: + """Encodes attributes into OTLP JSON format.""" + if not attributes: + return [] + + attribute_list = [] + for key, value in attributes.items(): + if value is None: + continue + + attribute = {"key": key} + attribute.update(_encode_attribute_value(value)) + attribute_list.append(attribute) + + return attribute_list + + +# pylint: disable=too-many-return-statements +def _encode_attribute_value(value: Any) -> Dict[str, Any]: + """Encodes a single attribute value into OTLP JSON format.""" + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + return {"doubleValue": value} + if isinstance(value, str): + return {"stringValue": value} + if isinstance(value, (list, tuple)): + if not value: + return {"arrayValue": {"values": []}} + + array_value = {"values": []} + for element in value: + element_value = _encode_attribute_value(element) + array_value["values"].append(element_value) + + return {"arrayValue": array_value} + if isinstance(value, bytes): + return {"bytesValue": base64.b64encode(value).decode("ascii")} + # Convert anything else to string + return {"stringValue": str(value)} + + +def _encode_events( + events: Sequence[Union[Event, Tuple[int, str, Dict[str, Any]]]], +) -> List[Dict[str, Any]]: + """Encodes span events into OTLP JSON format.""" + if not events: + return [] + + event_list = [] + + # Handle both Event objects and tuples + for event in events: + if ( + hasattr(event, "timestamp") + and hasattr(event, "name") + and hasattr(event, "attributes") + ): + # It's an Event object + timestamp_ns = _timestamp_to_ns(event.timestamp) + event_list.append( + { + "timeUnixNano": str(timestamp_ns), + "name": event.name, + "attributes": _encode_attributes(event.attributes), + "droppedAttributesCount": getattr( + event, "dropped_attributes_count", 0 + ), + } + ) + elif isinstance(event, tuple) and len(event) == 3: + # It's a tuple of (timestamp, name, attributes) + timestamp, name, attributes = event + timestamp_ns = _timestamp_to_ns(timestamp) + event_list.append( + { + "timeUnixNano": str(timestamp_ns), + "name": name, + "attributes": _encode_attributes(attributes), + "droppedAttributesCount": 0, # Not tracking dropped event attributes yet + } + ) + + return event_list + + +def _encode_links(links: Sequence[trace.Link]) -> List[Dict[str, Any]]: + """Encodes span links into OTLP JSON format.""" + if not links: + return [] + + link_list = [] + for link in links: + trace_id_bytes = link.context.trace_id.to_bytes(16, "big") + span_id_bytes = link.context.span_id.to_bytes(8, "big") + + link_data = { + "traceId": base64.b64encode(trace_id_bytes).decode("ascii"), + "spanId": base64.b64encode(span_id_bytes).decode("ascii"), + "attributes": _encode_attributes(link.attributes), + "droppedAttributesCount": 0, # Not tracking dropped link attributes yet + } + + if link.context.trace_state: + link_data["traceState"] = str(link.context.trace_state) + + link_list.append(link_data) + + return link_list + + +def _encode_status(status: Union[Status, StatusCode, None]) -> Dict[str, Any]: + """Encodes span status into OTLP JSON format.""" + if status is None: + return {"code": "STATUS_CODE_UNSET"} + + # Handle Status objects with status_code attribute + if hasattr(status, "status_code"): + status_code = status.status_code + if status_code == StatusCode.OK: + result = {"code": "STATUS_CODE_OK"} + elif status_code == StatusCode.ERROR: + result = {"code": "STATUS_CODE_ERROR"} + else: + result = {"code": "STATUS_CODE_UNSET"} + + # Add description if available + if hasattr(status, "description") and status.description: + result["message"] = status.description + + return result + + # Handle direct StatusCode values + if status == StatusCode.OK: + return {"code": "STATUS_CODE_OK"} + if status == StatusCode.ERROR: + return {"code": "STATUS_CODE_ERROR"} + return {"code": "STATUS_CODE_UNSET"} + + +def _get_span_kind_value(kind: trace.SpanKind) -> str: + """Maps the OpenTelemetry SpanKind to OTLP JSON values.""" + if kind == trace.SpanKind.SERVER: + return "SPAN_KIND_SERVER" + if kind == trace.SpanKind.CLIENT: + return "SPAN_KIND_CLIENT" + if kind == trace.SpanKind.PRODUCER: + return "SPAN_KIND_PRODUCER" + if kind == trace.SpanKind.CONSUMER: + return "SPAN_KIND_CONSUMER" + if kind == trace.SpanKind.INTERNAL: + return "SPAN_KIND_INTERNAL" + return "SPAN_KIND_UNSPECIFIED" + + +def _timestamp_to_ns(timestamp: Optional[int]) -> int: + """Converts a timestamp to nanoseconds.""" + if timestamp is None: + return 0 + + if timestamp > 1e10: # Already in nanoseconds + return timestamp + + return int(timestamp * 1e9) # Convert seconds to nanoseconds diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py new file mode 100644 index 00000000000..b21b8e8ba91 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_log_encoder.py @@ -0,0 +1,20 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from opentelemetry.exporter.otlp.json.common._internal._log_encoder import ( + encode_logs, +) + +__all__ = ["encode_logs"] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py new file mode 100644 index 00000000000..a4c621ef60f --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/metrics_encoder.py @@ -0,0 +1,20 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( + encode_metrics, +) + +__all__ = ["encode_metrics"] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py new file mode 100644 index 00000000000..71f2b321576 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/trace_encoder.py @@ -0,0 +1,20 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from opentelemetry.exporter.otlp.json.common._internal.trace_encoder import ( + encode_spans, +) + +__all__ = ["encode_spans"] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py new file mode 100644 index 00000000000..4effd145cba --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/version/__init__.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.1.0.dev" diff --git a/exporter/opentelemetry-exporter-otlp-json-common/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-common/test-requirements.txt new file mode 100644 index 00000000000..ebf1a5a122e --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/test-requirements.txt @@ -0,0 +1,17 @@ +asgiref==3.7.2 +Deprecated==1.2.14 +importlib-metadata==6.11.0 +iniconfig==2.0.0 +packaging==24.0 +pluggy==1.5.0 +py-cpuinfo==9.0.0 +pytest==7.4.4 +tomli==2.0.1 +typing_extensions==4.10.0 +wrapt==1.16.0 +zipp==3.19.2 +-e opentelemetry-api +-e opentelemetry-sdk +-e opentelemetry-semantic-conventions +-e tests/opentelemetry-test-utils +-e exporter/opentelemetry-exporter-otlp-json-common \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/__init__.py new file mode 100644 index 00000000000..b0a6f428417 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_attribute_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_attribute_encoder.py new file mode 100644 index 00000000000..bea5bca08aa --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_attribute_encoder.py @@ -0,0 +1,189 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=unsubscriptable-object +import unittest +from logging import ERROR +from typing import Any, Dict, Optional + +from opentelemetry.exporter.otlp.json.common._internal import ( + _encode_array, + _encode_attributes, + _encode_key_value, + _encode_span_id, + _encode_trace_id, + _encode_value, +) + + +class TestAttributeEncoder(unittest.TestCase): + def test_encode_attributes_all_kinds(self): + # Test encoding all kinds of attributes + result: Optional[Dict[str, Any]] = _encode_attributes( + { + "a": 1, # int + "b": 3.14, # float + "c": False, # bool + "hello": "world", # str + "greet": ["hola", "bonjour"], # Sequence[str] + "data": [1, 2], # Sequence[int] + "data_granular": [1.4, 2.4], # Sequence[float] + "binary_data": b"x00\x01\x02", # bytes + } + ) + + # Verify each key and value type + self.assertIsNotNone(result, "Result should not be None") + # Now we can safely use result as a dictionary since we've verified it's not None + assert ( + result is not None + ) # This helps type checkers understand result is not None + self.assertEqual(result["a"], 1) + self.assertEqual(result["b"], 3.14) + self.assertEqual(result["c"], False) + self.assertEqual(result["hello"], "world") + self.assertEqual(result["greet"], ["hola", "bonjour"]) + self.assertEqual(result["data"], [1, 2]) + self.assertEqual(result["data_granular"], [1.4, 2.4]) + self.assertIn("bytes_value", result["binary_data"]) # Base64 encoded + + def test_encode_attributes_error_list_none(self): + # Test handling of None in a list + with self.assertLogs(level=ERROR) as error: + result: Optional[Dict[str, Any]] = _encode_attributes( + {"a": 1, "bad_key": ["test", None, "test"], "b": 2} + ) + + # Verify error is logged + self.assertEqual(len(error.records), 1) + self.assertEqual(error.records[0].msg, "Failed to encode key %s: %s") + self.assertEqual(error.records[0].args[0], "bad_key") + self.assertIsInstance(error.records[0].args[1], Exception) + + # Verify other keys are still processed + self.assertIsNotNone(result, "Result should not be None") + # Now we can safely use result as a dictionary since we've verified it's not None + assert ( + result is not None + ) # This helps type checkers understand result is not None + self.assertEqual(result["a"], 1) + self.assertEqual(result["b"], 2) + self.assertNotIn("bad_key", result) + + def test_encode_attributes_error_logs_key(self): + # Test handling of None as a value + with self.assertLogs(level=ERROR) as error: + result: Optional[Dict[str, Any]] = _encode_attributes( + {"a": 1, "bad_key": None, "b": 2} + ) + + # Verify error is logged + self.assertEqual(len(error.records), 1) + self.assertEqual(error.records[0].msg, "Failed to encode key %s: %s") + self.assertEqual(error.records[0].args[0], "bad_key") + self.assertIsInstance(error.records[0].args[1], Exception) + + # Verify other keys are still processed + self.assertIsNotNone(result, "Result should not be None") + # Now we can safely use result as a dictionary since we've verified it's not None + assert ( + result is not None + ) # This helps type checkers understand result is not None + self.assertEqual(result["a"], 1) + self.assertEqual(result["b"], 2) + self.assertNotIn("bad_key", result) + + def test_encode_value(self): + # Test simple value encoding + self.assertEqual(_encode_value(123), 123) + self.assertEqual(_encode_value("test"), "test") + self.assertEqual(_encode_value(True), True) + self.assertEqual(_encode_value(3.14), 3.14) + + # Test array value encoding + self.assertEqual(_encode_value([1, 2, 3]), [1, 2, 3]) + + # Test mapping value encoding + result: Dict[str, Any] = _encode_value({"a": 1, "b": 2}) + self.assertIsNotNone(result, "Result should not be None") + # Now we can safely use result as a dictionary since we've verified it's not None + assert ( + result is not None + ) # This helps type checkers understand result is not None + self.assertIn("kvlist_value", result) + self.assertEqual(result["kvlist_value"]["a"], 1) + self.assertEqual(result["kvlist_value"]["b"], 2) + + # Test bytes value encoding + result_bytes: Dict[str, Any] = _encode_value(b"hello") + self.assertIsNotNone(result_bytes, "Result_bytes should not be None") + # Now we can safely use result_bytes as a dictionary since we've verified it's not None + assert ( + result_bytes is not None + ) # This helps type checkers understand result_bytes is not None + self.assertIn("bytes_value", result_bytes) + + # Test None with allow_null=True + self.assertIsNone(_encode_value(None, allow_null=True)) + + # Test None with allow_null=False (should raise an exception) + with self.assertRaises(Exception): + _encode_value(None, allow_null=False) + + # Test unsupported type (should raise an exception) + with self.assertRaises(Exception): + _encode_value(complex(1, 2)) + + def test_encode_array(self): + # Test simple array encoding + self.assertEqual(_encode_array([1, 2, 3]), [1, 2, 3]) + self.assertEqual(_encode_array(["a", "b"]), ["a", "b"]) + + # Test array with None values and allow_null=True + result = _encode_array([1, None, 2], allow_null=True) + self.assertEqual(result, [1, None, 2]) + + # Test array with None values and allow_null=False (should raise an exception) + with self.assertRaises(Exception): + _encode_array([1, None, 2], allow_null=False) + + def test_encode_key_value(self): + # Test key-value encoding + result = _encode_key_value("key", "value") + self.assertEqual(result, {"key": "value"}) + + result = _encode_key_value("num", 123) + self.assertEqual(result, {"num": 123}) + + # Test with None value and allow_null=True + result = _encode_key_value("null_key", None, allow_null=True) + self.assertEqual(result, {"null_key": None}) + + # Test with None value and allow_null=False (should raise an exception) + with self.assertRaises(Exception): + _encode_key_value("null_key", None, allow_null=False) + + def test_encode_trace_id(self): + # Test trace ID encoding + trace_id = 0x3E0C63257DE34C926F9EFCD03927272E + encoded = _encode_trace_id(trace_id) + self.assertEqual(encoded, "3e0c63257de34c926f9efcd03927272e") + self.assertEqual(len(encoded), 32) # Should be 32 hex characters + + def test_encode_span_id(self): + # Test span ID encoding + span_id = 0x6E0C63257DE34C92 + encoded = _encode_span_id(span_id) + self.assertEqual(encoded, "6e0c63257de34c92") + self.assertEqual(len(encoded), 16) # Should be 16 hex characters diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_backoff.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_backoff.py new file mode 100644 index 00000000000..1f743cb5443 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_backoff.py @@ -0,0 +1,49 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import TestCase + +from opentelemetry.exporter.otlp.json.common._internal import ( + _create_exp_backoff_generator, +) + + +class TestBackoffGenerator(TestCase): + def test_exp_backoff_generator(self): + # Test exponential backoff with no maximum + generator = _create_exp_backoff_generator() + self.assertEqual(next(generator), 1) + self.assertEqual(next(generator), 2) + self.assertEqual(next(generator), 4) + self.assertEqual(next(generator), 8) + self.assertEqual(next(generator), 16) + + def test_exp_backoff_generator_with_max(self): + # Test exponential backoff with a maximum value + generator = _create_exp_backoff_generator(max_value=4) + self.assertEqual(next(generator), 1) + self.assertEqual(next(generator), 2) + self.assertEqual(next(generator), 4) + self.assertEqual(next(generator), 4) # Capped at max_value + self.assertEqual(next(generator), 4) # Still capped at max_value + + def test_exp_backoff_generator_with_odd_max(self): + # Test with a max_value that's not in the sequence + generator = _create_exp_backoff_generator(max_value=11) + self.assertEqual(next(generator), 1) + self.assertEqual(next(generator), 2) + self.assertEqual(next(generator), 4) + self.assertEqual(next(generator), 8) + self.assertEqual(next(generator), 11) # Capped at max_value + self.assertEqual(next(generator), 11) # Still capped at max_value diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_log_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_log_encoder.py new file mode 100644 index 00000000000..4ebc738e8e5 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_log_encoder.py @@ -0,0 +1,265 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import unittest +from typing import List + +from opentelemetry._logs import SeverityNumber +from opentelemetry.exporter.otlp.json.common._log_encoder import encode_logs +from opentelemetry.sdk._logs import LogData, LogLimits +from opentelemetry.sdk._logs import LogRecord as SDKLogRecord +from opentelemetry.sdk.resources import Resource as SDKResource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.trace import TraceFlags + + +class TestLogEncoder(unittest.TestCase): + def test_encode(self): + # Create test log data + sdk_logs = self._get_sdk_log_data() + + # Encode logs to JSON + json_logs = encode_logs(sdk_logs) + + # Verify structure + self.assertIn("resourceLogs", json_logs) + self.assertEqual(len(json_logs["resourceLogs"]), 3) + + # Verify the content of the first resource log + resource_log = json_logs["resourceLogs"][0] + self.assertIn("resource", resource_log) + self.assertIn("scopeLogs", resource_log) + + # Convert to JSON and back to ensure it's JSON-serializable + json_str = json.dumps(json_logs) + parsed_json = json.loads(json_str) + self.assertEqual(len(parsed_json["resourceLogs"]), 3) + + def test_encode_no_body(self): + # Create test log data with no body + sdk_logs = self._get_sdk_log_data() + for log in sdk_logs: + log.log_record.body = None + + # Encode logs to JSON + json_logs = encode_logs(sdk_logs) + + # Verify structure + self.assertIn("resourceLogs", json_logs) + + # Verify the first log record has no body field + resource_log = json_logs["resourceLogs"][0] + scope_log = resource_log["scopeLogs"][0] + log_record = scope_log["logRecords"][0] + self.assertNotIn("body", log_record) + + def test_dropped_attributes_count(self): + # Create test log data with dropped attributes + sdk_logs = self._get_test_logs_dropped_attributes() + + # Encode logs to JSON + json_logs = encode_logs(sdk_logs) + + # Verify dropped attributes count + resource_log = json_logs["resourceLogs"][0] + scope_log = resource_log["scopeLogs"][0] + log_record = scope_log["logRecords"][0] + self.assertEqual(log_record["droppedAttributesCount"], 2) + + @staticmethod + def _get_sdk_log_data() -> List[LogData]: + """Create a test list of log data for encoding tests.""" + log1 = LogData( + log_record=SDKLogRecord( + timestamp=1644650195189786880, + observed_timestamp=1644650195189786881, + trace_id=89564621134313219400156819398935297684, + span_id=1312458408527513268, + trace_flags=TraceFlags(0x01), + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="Do not go gentle into that good night. Rage, rage against the dying of the light", + resource=SDKResource( + {"first_resource": "value"}, + "resource_schema_url", + ), + attributes={"a": 1, "b": "c"}, + ), + instrumentation_scope=InstrumentationScope( + "first_name", "first_version" + ), + ) + + log2 = LogData( + log_record=SDKLogRecord( + timestamp=1644650249738562048, + observed_timestamp=1644650249738562049, + trace_id=0, + span_id=0, + trace_flags=TraceFlags.DEFAULT, + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="Cooper, this is no time for caution!", + resource=SDKResource({"second_resource": "CASE"}), + attributes={}, + ), + instrumentation_scope=InstrumentationScope( + "second_name", "second_version" + ), + ) + + log3 = LogData( + log_record=SDKLogRecord( + timestamp=1644650427658989056, + observed_timestamp=1644650427658989057, + trace_id=271615924622795969659406376515024083555, + span_id=4242561578944770265, + trace_flags=TraceFlags(0x01), + severity_text="DEBUG", + severity_number=SeverityNumber.DEBUG, + body="To our galaxy", + resource=SDKResource({"second_resource": "CASE"}), + attributes={"a": 1, "b": "c"}, + ), + instrumentation_scope=None, + ) + + log4 = LogData( + log_record=SDKLogRecord( + timestamp=1644650584292683008, + observed_timestamp=1644650584292683009, + trace_id=212592107417388365804938480559624925555, + span_id=6077757853989569223, + trace_flags=TraceFlags(0x01), + severity_text="INFO", + severity_number=SeverityNumber.INFO, + body="Love is the one thing that transcends time and space", + resource=SDKResource( + {"first_resource": "value"}, + "resource_schema_url", + ), + attributes={"filename": "model.py", "func_name": "run_method"}, + ), + instrumentation_scope=InstrumentationScope( + "another_name", "another_version" + ), + ) + + log5 = LogData( + log_record=SDKLogRecord( + timestamp=1644650584292683009, + observed_timestamp=1644650584292683010, + trace_id=212592107417388365804938480559624925555, + span_id=6077757853989569445, + trace_flags=TraceFlags(0x01), + severity_text="INFO", + severity_number=SeverityNumber.INFO, + body={"error": None, "array_with_nones": [1, None, 2]}, + resource=SDKResource({}), + attributes={}, + ), + instrumentation_scope=InstrumentationScope( + "last_name", "last_version" + ), + ) + + log6 = LogData( + log_record=SDKLogRecord( + timestamp=1644650584292683022, + observed_timestamp=1644650584292683022, + trace_id=212592107417388365804938480559624925522, + span_id=6077757853989569222, + trace_flags=TraceFlags(0x01), + severity_text="ERROR", + severity_number=SeverityNumber.ERROR, + body="This instrumentation scope has a schema url", + resource=SDKResource( + {"first_resource": "value"}, + "resource_schema_url", + ), + attributes={"filename": "model.py", "func_name": "run_method"}, + ), + instrumentation_scope=InstrumentationScope( + "scope_with_url", + "scope_with_url_version", + "instrumentation_schema_url", + ), + ) + + log7 = LogData( + log_record=SDKLogRecord( + timestamp=1644650584292683033, + observed_timestamp=1644650584292683033, + trace_id=212592107417388365804938480559624925533, + span_id=6077757853989569233, + trace_flags=TraceFlags(0x01), + severity_text="FATAL", + severity_number=SeverityNumber.FATAL, + body="This instrumentation scope has a schema url and attributes", + resource=SDKResource( + {"first_resource": "value"}, + "resource_schema_url", + ), + attributes={"filename": "model.py", "func_name": "run_method"}, + ), + instrumentation_scope=InstrumentationScope( + "scope_with_attributes", + "scope_with_attributes_version", + "instrumentation_schema_url", + {"one": 1, "two": "2"}, + ), + ) + + return [log1, log2, log3, log4, log5, log6, log7] + + @staticmethod + def _get_test_logs_dropped_attributes() -> List[LogData]: + """Create a test list of log data with dropped attributes.""" + log1 = LogData( + log_record=SDKLogRecord( + timestamp=1644650195189786880, + trace_id=89564621134313219400156819398935297684, + span_id=1312458408527513268, + trace_flags=TraceFlags(0x01), + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="Do not go gentle into that good night. Rage, rage against the dying of the light", + resource=SDKResource({"first_resource": "value"}), + attributes={"a": 1, "b": "c", "user_id": "B121092"}, + limits=LogLimits(max_attributes=1), + ), + instrumentation_scope=InstrumentationScope( + "first_name", "first_version" + ), + ) + + log2 = LogData( + log_record=SDKLogRecord( + timestamp=1644650249738562048, + trace_id=0, + span_id=0, + trace_flags=TraceFlags.DEFAULT, + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="Cooper, this is no time for caution!", + resource=SDKResource({"second_resource": "CASE"}), + attributes={}, + ), + instrumentation_scope=InstrumentationScope( + "second_name", "second_version" + ), + ) + + return [log1, log2] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py new file mode 100644 index 00000000000..54b7a7f5170 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py @@ -0,0 +1,381 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import unittest + +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.sdk.metrics import Exemplar +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + Buckets, + ExponentialHistogramDataPoint, + HistogramDataPoint, + Metric, + MetricsData, + ResourceMetrics, + ScopeMetrics, +) +from opentelemetry.sdk.metrics.export import ( + ExponentialHistogram as ExponentialHistogramType, +) +from opentelemetry.sdk.metrics.export import Histogram as HistogramType +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import ( + InstrumentationScope as SDKInstrumentationScope, +) +from opentelemetry.test.metrictestutil import _generate_sum + + +class TestMetricsEncoder(unittest.TestCase): + span_id = int("6e0c63257de34c92", 16) + trace_id = int("d4cda95b652f4a1592b449d5929fda1b", 16) + + histogram = Metric( + name="histogram", + description="foo", + unit="s", + data=HistogramType( + data_points=[ + HistogramDataPoint( + attributes={"a": 1, "b": True}, + start_time_unix_nano=1641946016139533244, + time_unix_nano=1641946016139533244, + exemplars=[ + Exemplar( + {"filtered": "banana"}, + 298.0, + 1641946016139533400, + span_id, + trace_id, + ), + Exemplar( + {"filtered": "banana"}, + 298.0, + 1641946016139533400, + None, + None, + ), + ], + count=5, + sum=67, + bucket_counts=[1, 4], + explicit_bounds=[10.0, 20.0], + min=8, + max=18, + ) + ], + aggregation_temporality=AggregationTemporality.DELTA, + ), + ) + + def test_encode_sum_int(self): + # Test encoding an integer sum metric + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + attributes={"a": 1, "b": False}, + schema_url="resource_schema_url", + ), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope( + name="first_name", + version="first_version", + schema_url="instrumentation_scope_schema_url", + ), + metrics=[_generate_sum("sum_int", 33)], + schema_url="instrumentation_scope_schema_url", + ) + ], + schema_url="resource_schema_url", + ) + ] + ) + + json_metrics = encode_metrics(metrics_data) + + # Verify structure + self.assertIn("resourceMetrics", json_metrics) + self.assertEqual(len(json_metrics["resourceMetrics"]), 1) + + # Convert to JSON and back to ensure it's serializable + json_str = json.dumps(json_metrics) + # Verify serialization works + json.loads(json_str) + + # Verify content + resource_metrics = json_metrics["resourceMetrics"][0] + self.assertEqual(resource_metrics["schemaUrl"], "resource_schema_url") + self.assertEqual(len(resource_metrics["scopeMetrics"]), 1) + + scope_metrics = resource_metrics["scopeMetrics"][0] + self.assertEqual(scope_metrics["scope"]["name"], "first_name") + self.assertEqual(scope_metrics["scope"]["version"], "first_version") + self.assertEqual(len(scope_metrics["metrics"]), 1) + + metric = scope_metrics["metrics"][0] + self.assertEqual(metric["name"], "sum_int") + self.assertEqual(metric["unit"], "s") + self.assertEqual(metric["description"], "foo") + self.assertIn("sum", metric) + + sum_data = metric["sum"] + # In ProtoJSON format, the aggregation temporality is a string + self.assertEqual( + sum_data["aggregationTemporality"], + "AGGREGATION_TEMPORALITY_CUMULATIVE", + ) + self.assertTrue(sum_data["isMonotonic"]) + self.assertEqual(len(sum_data["dataPoints"]), 1) + + data_point = sum_data["dataPoints"][0] + self.assertEqual( + data_point["asInt"], "33" + ) # Should be a string to avoid int overflow + + def test_encode_histogram(self): + # Test encoding a histogram metric + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + attributes={"a": 1, "b": False}, + schema_url="resource_schema_url", + ), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope( + name="first_name", + version="first_version", + schema_url="instrumentation_scope_schema_url", + ), + metrics=[self.histogram], + schema_url="instrumentation_scope_schema_url", + ) + ], + schema_url="resource_schema_url", + ) + ] + ) + + json_metrics = encode_metrics(metrics_data) + + # Verify structure + self.assertIn("resourceMetrics", json_metrics) + + # Convert to JSON and back to ensure it's serializable + json_str = json.dumps(json_metrics) + # Verify serialization works + json.loads(json_str) + + # Verify content + resource_metrics = json_metrics["resourceMetrics"][0] + scope_metrics = resource_metrics["scopeMetrics"][0] + metric = scope_metrics["metrics"][0] + + self.assertEqual(metric["name"], "histogram") + self.assertIn("histogram", metric) + + histogram_data = metric["histogram"] + # In ProtoJSON format, the aggregation temporality is a string + self.assertEqual( + histogram_data["aggregationTemporality"], + "AGGREGATION_TEMPORALITY_DELTA", + ) + self.assertEqual(len(histogram_data["dataPoints"]), 1) + + data_point = histogram_data["dataPoints"][0] + self.assertEqual(data_point["sum"], 67) + self.assertEqual( + data_point["count"], "5" + ) # Should be a string to avoid int overflow + self.assertEqual( + data_point["bucketCounts"], ["1", "4"] + ) # Should be strings + self.assertEqual(data_point["explicitBounds"], [10.0, 20.0]) + self.assertEqual(data_point["min"], 8) + self.assertEqual(data_point["max"], 18) + + # Verify exemplars + self.assertEqual(len(data_point["exemplars"]), 2) + + exemplar = data_point["exemplars"][0] + self.assertEqual(exemplar["timeUnixNano"], str(1641946016139533400)) + # In ProtoJSON format, span IDs and trace IDs are base64-encoded + self.assertIn("spanId", exemplar) + self.assertIn("traceId", exemplar) + # We don't check the exact values since they're base64-encoded + self.assertEqual(exemplar["asDouble"], 298.0) + + exemplar2 = data_point["exemplars"][1] + self.assertEqual(exemplar2["timeUnixNano"], str(1641946016139533400)) + self.assertEqual(exemplar2["asDouble"], 298.0) + self.assertNotIn("spanId", exemplar2) + self.assertNotIn("traceId", exemplar2) + + def test_encode_exponential_histogram(self): + exponential_histogram = Metric( + name="exponential_histogram", + description="description", + unit="unit", + data=ExponentialHistogramType( + data_points=[ + ExponentialHistogramDataPoint( + attributes={"a": 1, "b": True}, + start_time_unix_nano=0, + time_unix_nano=1, + count=2, + sum=3, + scale=4, + zero_count=5, + positive=Buckets(offset=6, bucket_counts=[7, 8]), + negative=Buckets(offset=9, bucket_counts=[10, 11]), + flags=12, + min=13.0, + max=14.0, + ) + ], + aggregation_temporality=AggregationTemporality.DELTA, + ), + ) + + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + attributes={"a": 1, "b": False}, + schema_url="resource_schema_url", + ), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope( + name="first_name", + version="first_version", + schema_url="instrumentation_scope_schema_url", + ), + metrics=[exponential_histogram], + schema_url="instrumentation_scope_schema_url", + ) + ], + schema_url="resource_schema_url", + ) + ] + ) + + json_metrics = encode_metrics(metrics_data) + + # Convert to JSON and back to ensure it's serializable + json_str = json.dumps(json_metrics) + # Verify serialization works + json.loads(json_str) + + # Verify content + resource_metrics = json_metrics["resourceMetrics"][0] + scope_metrics = resource_metrics["scopeMetrics"][0] + metric = scope_metrics["metrics"][0] + + self.assertEqual(metric["name"], "exponential_histogram") + # In ProtoJSON format, it's "exponentialHistogram" not "exponentialHistogram" + self.assertIn("exponentialHistogram", metric) + + histogram_data = metric["exponentialHistogram"] + # In ProtoJSON format, the aggregation temporality is a string + self.assertEqual( + histogram_data["aggregationTemporality"], + "AGGREGATION_TEMPORALITY_DELTA", + ) + self.assertEqual(len(histogram_data["dataPoints"]), 1) + + data_point = histogram_data["dataPoints"][0] + self.assertEqual(data_point["sum"], 3) + self.assertEqual(data_point["count"], "2") # Should be a string + self.assertEqual(data_point["scale"], 4) + self.assertEqual(data_point["zeroCount"], "5") # Should be a string + + self.assertEqual(data_point["positive"]["offset"], 6) + self.assertEqual( + data_point["positive"]["bucketCounts"], ["7", "8"] + ) # Should be strings + + self.assertEqual(data_point["negative"]["offset"], 9) + self.assertEqual( + data_point["negative"]["bucketCounts"], ["10", "11"] + ) # Should be strings + + self.assertEqual(data_point["flags"], 12) + self.assertEqual(data_point["min"], 13.0) + self.assertEqual(data_point["max"], 14.0) + + def test_encoding_exception(self): + # Create a metric with a value that will cause an encoding error + class BadMetric: + def __init__(self): + self.data = BadData() + self.name = "bad_metric" + self.description = "bad" + self.unit = "bad" + + class BadData: + def __init__(self): + pass + + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + attributes={}, + ), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope( + name="test", + version="test", + ), + metrics=[BadMetric()], + schema_url="", + ) + ], + schema_url="", + ) + ] + ) + + # The new implementation doesn't raise an exception for unsupported data types, + # it just ignores them. So we just verify that encoding completes without error. + json_metrics = encode_metrics(metrics_data) + + # Verify the basic structure is correct + self.assertIn("resourceMetrics", json_metrics) + self.assertEqual(len(json_metrics["resourceMetrics"]), 1) + + # Verify the metric is included but without any data type + resource_metrics = json_metrics["resourceMetrics"][0] + scope_metrics = resource_metrics["scopeMetrics"][0] + metrics = scope_metrics["metrics"] + + self.assertEqual(len(metrics), 1) + metric = metrics[0] + self.assertEqual(metric["name"], "bad_metric") + self.assertEqual(metric["description"], "bad") + self.assertEqual(metric["unit"], "bad") + + # Verify no data type field was added + self.assertNotIn("gauge", metric) + self.assertNotIn("sum", metric) + self.assertNotIn("histogram", metric) + self.assertNotIn("exponentialHistogram", metric) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_trace_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_trace_encoder.py new file mode 100644 index 00000000000..80be9fc8c74 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_trace_encoder.py @@ -0,0 +1,232 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import unittest +from typing import List + +from opentelemetry.exporter.otlp.json.common._internal.trace_encoder import ( + _encode_status, + _get_span_kind_value, +) +from opentelemetry.exporter.otlp.json.common.trace_encoder import encode_spans +from opentelemetry.sdk.trace import Event as SDKEvent +from opentelemetry.sdk.trace import Resource as SDKResource +from opentelemetry.sdk.trace import SpanContext as SDKSpanContext +from opentelemetry.sdk.trace import _Span as SDKSpan +from opentelemetry.sdk.util.instrumentation import ( + InstrumentationScope as SDKInstrumentationScope, +) +from opentelemetry.trace import Link as SDKLink +from opentelemetry.trace import SpanKind as SDKSpanKind +from opentelemetry.trace import TraceFlags as SDKTraceFlags +from opentelemetry.trace.status import Status as SDKStatus +from opentelemetry.trace.status import StatusCode as SDKStatusCode + + +class TestTraceEncoder(unittest.TestCase): + def test_encode_spans(self): + # Create test spans + otel_spans = self.get_test_span_list() + + # Encode spans to JSON + json_spans = encode_spans(otel_spans) + + # Verify the structure is correct + self.assertIn("resourceSpans", json_spans) + self.assertEqual(len(json_spans["resourceSpans"]), 3) + + # Verify the content of the first resource span + resource_span = json_spans["resourceSpans"][0] + self.assertIn("resource", resource_span) + self.assertIn("scopeSpans", resource_span) + + # Convert to JSON and back to ensure it's JSON-serializable + json_str = json.dumps(json_spans) + parsed_json = json.loads(json_str) + self.assertEqual(len(parsed_json["resourceSpans"]), 3) + + def test_encode_status(self): + # Test encoding of status codes + status = SDKStatus( + status_code=SDKStatusCode.ERROR, description="Error description" + ) + json_status = _encode_status(status) + + # In ProtoJSON format, status code is a string + self.assertEqual(json_status["code"], "STATUS_CODE_ERROR") + self.assertEqual(json_status["message"], "Error description") + + # Test with empty description + status = SDKStatus(status_code=SDKStatusCode.OK) + json_status = _encode_status(status) + + # In ProtoJSON format, status code is a string + self.assertEqual(json_status["code"], "STATUS_CODE_OK") + + # Test with UNSET status + status = SDKStatus(status_code=SDKStatusCode.UNSET) + json_status = _encode_status(status) + + # In ProtoJSON format, status code is a string + self.assertEqual(json_status["code"], "STATUS_CODE_UNSET") + + def test_span_kind_mapping(self): + # Verify all span kinds are mapped correctly to ProtoJSON string values + self.assertEqual( + _get_span_kind_value(SDKSpanKind.INTERNAL), "SPAN_KIND_INTERNAL" + ) + self.assertEqual( + _get_span_kind_value(SDKSpanKind.SERVER), "SPAN_KIND_SERVER" + ) + self.assertEqual( + _get_span_kind_value(SDKSpanKind.CLIENT), "SPAN_KIND_CLIENT" + ) + self.assertEqual( + _get_span_kind_value(SDKSpanKind.PRODUCER), "SPAN_KIND_PRODUCER" + ) + self.assertEqual( + _get_span_kind_value(SDKSpanKind.CONSUMER), "SPAN_KIND_CONSUMER" + ) + + @staticmethod + def get_test_span_list() -> List[SDKSpan]: + """Create a test list of spans for encoding tests.""" + trace_id = 0x3E0C63257DE34C926F9EFCD03927272E + + base_time = 683647322 * 10**9 # in ns + start_times = ( + base_time, + base_time + 150 * 10**6, + base_time + 300 * 10**6, + base_time + 400 * 10**6, + base_time + 500 * 10**6, + base_time + 600 * 10**6, + ) + end_times = ( + start_times[0] + (50 * 10**6), + start_times[1] + (100 * 10**6), + start_times[2] + (200 * 10**6), + start_times[3] + (300 * 10**6), + start_times[4] + (400 * 10**6), + start_times[5] + (500 * 10**6), + ) + + parent_span_context = SDKSpanContext( + trace_id, 0x1111111111111111, is_remote=True + ) + + other_context = SDKSpanContext( + trace_id, 0x2222222222222222, is_remote=False + ) + + span1 = SDKSpan( + name="test-span-1", + context=SDKSpanContext( + trace_id, + 0x34BF92DEEFC58C92, + is_remote=False, + trace_flags=SDKTraceFlags(SDKTraceFlags.SAMPLED), + ), + parent=parent_span_context, + events=( + SDKEvent( + name="event0", + timestamp=base_time + 50 * 10**6, + attributes={ + "annotation_bool": True, + "annotation_string": "annotation_test", + "key_float": 0.3, + }, + ), + ), + links=( + SDKLink(context=other_context, attributes={"key_bool": True}), + ), + resource=SDKResource({}, "resource_schema_url"), + ) + span1.start(start_time=start_times[0]) + span1.set_attribute("key_bool", False) + span1.set_attribute("key_string", "hello_world") + span1.set_attribute("key_float", 111.22) + span1.set_status(SDKStatus(SDKStatusCode.ERROR, "Example description")) + span1.end(end_time=end_times[0]) + + span2 = SDKSpan( + name="test-span-2", + context=parent_span_context, + parent=None, + resource=SDKResource(attributes={"key_resource": "some_resource"}), + ) + span2.start(start_time=start_times[1]) + span2.end(end_time=end_times[1]) + + span3 = SDKSpan( + name="test-span-3", + context=other_context, + parent=None, + resource=SDKResource(attributes={"key_resource": "some_resource"}), + ) + span3.start(start_time=start_times[2]) + span3.set_attribute("key_string", "hello_world") + span3.end(end_time=end_times[2]) + + span4 = SDKSpan( + name="test-span-4", + context=other_context, + parent=None, + resource=SDKResource({}, "resource_schema_url"), + instrumentation_scope=SDKInstrumentationScope( + name="name", version="version" + ), + ) + span4.start(start_time=start_times[3]) + span4.end(end_time=end_times[3]) + + span5 = SDKSpan( + name="test-span-5", + context=other_context, + parent=None, + resource=SDKResource( + attributes={"key_resource": "another_resource"}, + schema_url="resource_schema_url", + ), + instrumentation_scope=SDKInstrumentationScope( + name="scope_1_name", + version="scope_1_version", + schema_url="scope_1_schema_url", + ), + ) + span5.start(start_time=start_times[4]) + span5.end(end_time=end_times[4]) + + span6 = SDKSpan( + name="test-span-6", + context=other_context, + parent=None, + resource=SDKResource( + attributes={"key_resource": "another_resource"}, + schema_url="resource_schema_url", + ), + instrumentation_scope=SDKInstrumentationScope( + name="scope_2_name", + version="scope_2_version", + schema_url="scope_2_schema_url", + attributes={"one": "1", "two": 2}, + ), + ) + span6.start(start_time=start_times[5]) + span6.end(end_time=end_times[5]) + + return [span1, span2, span3, span4, span5, span6] diff --git a/exporter/opentelemetry-exporter-otlp-json-http/README.rst b/exporter/opentelemetry-exporter-otlp-json-http/README.rst new file mode 100644 index 00000000000..3d950b17c3a --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/README.rst @@ -0,0 +1,91 @@ +OpenTelemetry Collector JSON over HTTP Exporter +============================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-exporter-otlp-json-http.svg + :target: https://pypi.org/project/opentelemetry-exporter-otlp-json-http/ + +This library allows to export data to the OpenTelemetry Collector using the OpenTelemetry Protocol using JSON over HTTP. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-otlp-json-http + + +Usage +----- + +The **OTLP JSON HTTP Exporter** allows to export `OpenTelemetry`_ traces, metrics, and logs to the +`OTLP`_ collector or any compatible receiver, using JSON encoding over HTTP. + +.. _OTLP: https://github.com/open-telemetry/opentelemetry-collector/ +.. _OpenTelemetry: https://github.com/open-telemetry/opentelemetry-python/ + +.. code:: python + + from opentelemetry import trace + from opentelemetry.exporter.otlp.json.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + # Resource can be required for some backends, e.g. Jaeger + resource = Resource(attributes={ + "service.name": "service" + }) + + trace.set_tracer_provider(TracerProvider(resource=resource)) + tracer = trace.get_tracer(__name__) + + otlp_exporter = OTLPSpanExporter() + + span_processor = BatchSpanProcessor(otlp_exporter) + + trace.get_tracer_provider().add_span_processor(span_processor) + + with tracer.start_as_current_span("foo"): + print("Hello world!") + +Environment Variables +-------------------- + +You can configure the exporter using environment variables: + +- ``OTEL_EXPORTER_OTLP_ENDPOINT``: The base endpoint URL (for all signals) +- ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT``: The trace-specific endpoint URL (overrides the base endpoint) +- ``OTEL_EXPORTER_OTLP_METRICS_ENDPOINT``: The metrics-specific endpoint URL (overrides the base endpoint) +- ``OTEL_EXPORTER_OTLP_LOGS_ENDPOINT``: The logs-specific endpoint URL (overrides the base endpoint) +- ``OTEL_EXPORTER_OTLP_HEADERS``: The headers to include in all requests +- ``OTEL_EXPORTER_OTLP_TRACES_HEADERS``: The headers to include in trace requests +- ``OTEL_EXPORTER_OTLP_METRICS_HEADERS``: The headers to include in metrics requests +- ``OTEL_EXPORTER_OTLP_LOGS_HEADERS``: The headers to include in logs requests +- ``OTEL_EXPORTER_OTLP_TIMEOUT``: The timeout (in seconds) for all requests +- ``OTEL_EXPORTER_OTLP_TRACES_TIMEOUT``: The timeout (in seconds) for trace requests +- ``OTEL_EXPORTER_OTLP_METRICS_TIMEOUT``: The timeout (in seconds) for metrics requests +- ``OTEL_EXPORTER_OTLP_LOGS_TIMEOUT``: The timeout (in seconds) for logs requests +- ``OTEL_EXPORTER_OTLP_COMPRESSION``: The compression format to use for all requests +- ``OTEL_EXPORTER_OTLP_TRACES_COMPRESSION``: The compression format to use for trace requests +- ``OTEL_EXPORTER_OTLP_METRICS_COMPRESSION``: The compression format to use for metrics requests +- ``OTEL_EXPORTER_OTLP_LOGS_COMPRESSION``: The compression format to use for logs requests +- ``OTEL_EXPORTER_OTLP_CERTIFICATE``: Path to the CA certificate to verify server's identity +- ``OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE``: Path to the CA certificate for trace requests +- ``OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE``: Path to the CA certificate for metrics requests +- ``OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE``: Path to the CA certificate for logs requests +- ``OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE``: Path to client certificate +- ``OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE``: Path to client certificate for trace requests +- ``OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE``: Path to client certificate for metrics requests +- ``OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE``: Path to client certificate for logs requests +- ``OTEL_EXPORTER_OTLP_CLIENT_KEY``: Path to client key +- ``OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY``: Path to client key for trace requests +- ``OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY``: Path to client key for metrics requests +- ``OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY``: Path to client key for logs requests + +References +---------- + +* `OpenTelemetry `_ +* `OpenTelemetry Protocol Specification `_ \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml b/exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml new file mode 100644 index 00000000000..2bbbd42e5fb --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-exporter-otlp-json-http" +dynamic = ["version"] +description = "OpenTelemetry Collector JSON over HTTP Exporter" +readme = "README.rst" +license = {text = "Apache-2.0"} +requires-python = ">=3.8" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: OpenTelemetry", + "Framework :: OpenTelemetry :: Exporters", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp-json-common", + "requests ~= 2.7", +] + +[project.entry-points.opentelemetry_traces_exporter] +otlp_json_http = "opentelemetry.exporter.otlp.json.http.trace_exporter:OTLPSpanExporter" + +[project.entry-points.opentelemetry_metrics_exporter] +otlp_json_http = "opentelemetry.exporter.otlp.json.http.metric_exporter:OTLPMetricExporter" + +[project.entry-points.opentelemetry_logs_exporter] +otlp_json_http = "opentelemetry.exporter.otlp.json.http._log_exporter:OTLPLogExporter" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/exporter/opentelemetry-exporter-otlp-json-http" +Repository = "https://github.com/open-telemetry/opentelemetry-python" + +[tool.hatch.version] +path = "src/opentelemetry/exporter/otlp/json/http/version/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py new file mode 100644 index 00000000000..f1d5740cf85 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py @@ -0,0 +1,58 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +This library allows to export tracing data to an OTLP collector using JSON over HTTP. + +Usage +----- + +The **OTLP JSON HTTP Exporter** allows to export `OpenTelemetry`_ traces, metrics, and logs to the +`OTLP`_ collector, using JSON encoding over HTTP. + +You can configure the exporter with the following environment variables: + +- :envvar:`OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` +- :envvar:`OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` +- :envvar:`OTEL_EXPORTER_OTLP_TRACES_HEADERS` +- :envvar:`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` +- :envvar:`OTEL_EXPORTER_OTLP_TRACES_COMPRESSION` +- :envvar:`OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE` +- :envvar:`OTEL_EXPORTER_OTLP_TIMEOUT` +- :envvar:`OTEL_EXPORTER_OTLP_PROTOCOL` +- :envvar:`OTEL_EXPORTER_OTLP_HEADERS` +- :envvar:`OTEL_EXPORTER_OTLP_ENDPOINT` +- :envvar:`OTEL_EXPORTER_OTLP_COMPRESSION` +- :envvar:`OTEL_EXPORTER_OTLP_CERTIFICATE` + +.. _OTLP: https://github.com/open-telemetry/opentelemetry-collector/ +.. _OpenTelemetry: https://github.com/open-telemetry/opentelemetry-python/ +""" + +import enum + +from .version import __version__ + +_OTLP_JSON_HTTP_HEADERS = { + "Content-Type": "application/json", + "User-Agent": "OTel-OTLP-Exporter-Python/" + __version__, +} + + +# pylint: disable=invalid-name +class Compression(enum.Enum): + NoCompression = "none" + Deflate = "deflate" + Gzip = "gzip" diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py new file mode 100644 index 00000000000..33debf465ae --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py @@ -0,0 +1,265 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OTLP Log Exporter for OpenTelemetry.""" + +import gzip +import json +import logging +import zlib +from io import BytesIO +from os import environ +from time import sleep +from typing import Dict, Optional, Sequence + +import requests + +from opentelemetry.exporter.otlp.json.common._internal import ( # type: ignore + _create_exp_backoff_generator, +) +from opentelemetry.exporter.otlp.json.common._log_encoder import ( + encode_logs, # type: ignore +) +from opentelemetry.exporter.otlp.json.http import ( + _OTLP_JSON_HTTP_HEADERS, + Compression, +) +from opentelemetry.sdk._logs import LogData +from opentelemetry.sdk._logs.export import ( + LogExporter, + LogExportResult, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_LOGS_COMPRESSION, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + OTEL_EXPORTER_OTLP_LOGS_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.util.re import parse_env_headers + +_logger = logging.getLogger(__name__) + + +DEFAULT_COMPRESSION = Compression.NoCompression +DEFAULT_ENDPOINT = "http://localhost:4318/" +DEFAULT_LOGS_EXPORT_PATH = "v1/logs" +DEFAULT_TIMEOUT = 10 # in seconds + + +class OTLPLogExporter(LogExporter): + """OTLP log exporter for JSON over HTTP. + + Args: + endpoint: The endpoint to send requests to. The default is + "http://localhost:4318/v1/logs" + certificate_file: Path to the CA certificate file to validate peers against. + If None or True, the default certificates will be used. + If False, peers will not be validated. + client_key_file: Path to client private key file for TLS client auth. + client_certificate_file: Path to client certificate file for TLS client auth. + headers: Map of additional HTTP headers to add to requests. + timeout: The maximum amount of time to wait for an export to complete. + The default is 10 seconds. + compression: Compression method to use for payloads. + The default is None, which means no compression will be used. + session: Session to use for the HTTP requests. If None, a new session + will be created for each export. + """ + + _MAX_RETRY_TIMEOUT = 64 + + # pylint: disable=too-many-positional-arguments + def __init__( + self, + endpoint: Optional[str] = None, + certificate_file: Optional[str] = None, + client_key_file: Optional[str] = None, + client_certificate_file: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None, + compression: Optional[Compression] = None, + session: Optional[requests.Session] = None, + ): + self._endpoint = endpoint or environ.get( + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + _append_logs_path( + environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) + ), + ) + # Keeping these as instance variables because they are used in tests + self._certificate_file = certificate_file or environ.get( + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + self._client_key_file = client_key_file or environ.get( + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY, None), + ) + self._client_certificate_file = client_certificate_file or environ.get( + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, None), + ) + self._client_cert = ( + (self._client_certificate_file, self._client_key_file) + if self._client_certificate_file and self._client_key_file + else self._client_certificate_file + ) + headers_string = environ.get( + OTEL_EXPORTER_OTLP_LOGS_HEADERS, + environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""), + ) + self._headers = headers or parse_env_headers( + headers_string, liberal=True + ) + self._timeout = timeout or int( + environ.get( + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, + environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, DEFAULT_TIMEOUT), + ) + ) + self._compression = compression or _compression_from_env() + self._session = session or requests.Session() + self._session.headers.update(self._headers) + self._session.headers.update(_OTLP_JSON_HTTP_HEADERS) + if self._compression is not Compression.NoCompression: + self._session.headers.update( + {"Content-Encoding": self._compression.value} + ) + self._shutdown = False + + def _export(self, serialized_data: bytes): + data = serialized_data + if self._compression == Compression.Gzip: + gzip_data = BytesIO() + with gzip.GzipFile(fileobj=gzip_data, mode="w") as gzip_stream: + gzip_stream.write(serialized_data) + data = gzip_data.getvalue() + elif self._compression == Compression.Deflate: + data = zlib.compress(serialized_data) + + return self._session.post( + url=self._endpoint, + data=data, + verify=self._certificate_file, + timeout=self._timeout, + cert=self._client_cert, + ) + + @staticmethod + def _retryable(resp: requests.Response) -> bool: + if resp.status_code == 408: + return True + if resp.status_code >= 500 and resp.status_code <= 599: + return True + return False + + def export(self, batch: Sequence[LogData]) -> LogExportResult: + """Export logs to OTLP collector via JSON over HTTP. + + Args: + batch: The list of log data to export. + + Returns: + The result of the export. + """ + # After the call to Shutdown subsequent calls to Export are + # not allowed and should return a Failure result. + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring batch") + return LogExportResult.FAILURE + + # Use the proper encoder that follows ProtoJSON format + json_logs = encode_logs(batch) + serialized_data = json.dumps(json_logs).encode("utf-8") + + for delay in _create_exp_backoff_generator( + max_value=self._MAX_RETRY_TIMEOUT + ): + if delay == self._MAX_RETRY_TIMEOUT: + return LogExportResult.FAILURE + + resp = self._export(serialized_data) + # pylint: disable=no-else-return + if resp.ok: + return LogExportResult.SUCCESS + elif self._retryable(resp): + _logger.warning( + "Transient error %s encountered while exporting logs batch, retrying in %ss.", + resp.reason, + delay, + ) + sleep(delay) + continue + else: + _logger.error( + "Failed to export logs batch code: %s, reason: %s", + resp.status_code, + resp.text, + ) + return LogExportResult.FAILURE + return LogExportResult.FAILURE + + @staticmethod + def force_flush(timeout_millis: float = 10_000) -> bool: + """Force flush is not implemented for this exporter. + + This method is kept for API compatibility. It does nothing. + + Args: + timeout_millis: The maximum amount of time to wait for logs to be + exported. + + Returns: + True, because nothing was buffered. + """ + return True + + def shutdown(self): + """Shuts down the exporter. + + Called when the SDK is shut down. + """ + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._session.close() + self._shutdown = True + + +def _compression_from_env() -> Compression: + compression = ( + environ.get( + OTEL_EXPORTER_OTLP_LOGS_COMPRESSION, + environ.get(OTEL_EXPORTER_OTLP_COMPRESSION, "none"), + ) + .lower() + .strip() + ) + return Compression(compression) + + +def _append_logs_path(endpoint: str) -> str: + if endpoint.endswith("/"): + return endpoint + DEFAULT_LOGS_EXPORT_PATH + return endpoint + f"/{DEFAULT_LOGS_EXPORT_PATH}" diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py new file mode 100644 index 00000000000..32077db4c0b --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py @@ -0,0 +1,293 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OTLP Metric Exporter for OpenTelemetry.""" + +from __future__ import annotations + +import gzip +import json +import logging +import zlib +from io import BytesIO +from os import environ +from time import sleep + +import requests + +from opentelemetry.exporter.otlp.json.common._internal import ( # type: ignore + _create_exp_backoff_generator, +) +from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( # type: ignore + OTLPMetricExporterMixin, +) +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( # type: ignore + encode_metrics, +) +from opentelemetry.exporter.otlp.json.http import ( + _OTLP_JSON_HTTP_HEADERS, + Compression, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_METRICS_COMPRESSION, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + OTEL_EXPORTER_OTLP_METRICS_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_TIMEOUT, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.sdk.metrics._internal.aggregation import Aggregation +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + MetricExporter, + MetricExportResult, + MetricsData, +) +from opentelemetry.util.re import parse_env_headers + +_logger = logging.getLogger(__name__) + + +DEFAULT_COMPRESSION = Compression.NoCompression +DEFAULT_ENDPOINT = "http://localhost:4318/" +DEFAULT_METRICS_EXPORT_PATH = "v1/metrics" +DEFAULT_TIMEOUT = 10 # in seconds + + +class OTLPMetricExporter(MetricExporter, OTLPMetricExporterMixin): + """OTLP metrics exporter for JSON over HTTP. + + Args: + endpoint: The endpoint to send requests to. The default is + "http://localhost:4318/v1/metrics" + certificate_file: Path to the CA certificate file to validate peers against. + If None or True, the default certificates will be used. + If False, peers will not be validated. + client_key_file: Path to client private key file for TLS client auth. + client_certificate_file: Path to client certificate file for TLS client auth. + headers: Map of additional HTTP headers to add to requests. + timeout: The maximum amount of time to wait for an export to complete. + The default is 10 seconds. + compression: Compression method to use for payloads. + The default is None, which means no compression will be used. + session: Session to use for the HTTP requests. If None, a new session + will be created for each export. + preferred_temporality: Dictionary mapping instrument classes to their + preferred temporality. If not specified, the default temporality + mapping will be used. + preferred_aggregation: Dictionary mapping instrument classes to their + preferred aggregation. If not specified, the default aggregation + mapping will be used. + """ + + _MAX_RETRY_TIMEOUT = 64 + + # pylint: disable=too-many-positional-arguments + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: dict[str, str] | None = None, + timeout: int | None = None, + compression: Compression | None = None, + session: requests.Session | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + ): + # Call the parent class's __init__ method + super().__init__( + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + # Call the _common_configuration method to initialize _preferred_temporality and _preferred_aggregation + self._common_configuration( + preferred_temporality=preferred_temporality, + preferred_aggregation=preferred_aggregation, + ) + self._endpoint = endpoint or environ.get( + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + _append_metrics_path( + environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) + ), + ) + self._certificate_file = certificate_file or environ.get( + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + self._client_key_file = client_key_file or environ.get( + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY, None), + ) + self._client_certificate_file = client_certificate_file or environ.get( + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, None), + ) + self._client_cert = ( + (self._client_certificate_file, self._client_key_file) + if self._client_certificate_file and self._client_key_file + else self._client_certificate_file + ) + headers_string = environ.get( + OTEL_EXPORTER_OTLP_METRICS_HEADERS, + environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""), + ) + self._headers = headers or parse_env_headers( + headers_string, liberal=True + ) + self._timeout = timeout or int( + environ.get( + OTEL_EXPORTER_OTLP_METRICS_TIMEOUT, + environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, DEFAULT_TIMEOUT), + ) + ) + self._compression = compression or _compression_from_env() + self._session = session or requests.Session() + self._session.headers.update(self._headers) + self._session.headers.update(_OTLP_JSON_HTTP_HEADERS) + if self._compression is not Compression.NoCompression: + self._session.headers.update( + {"Content-Encoding": self._compression.value} + ) + + def _export(self, serialized_data: bytes): + data = serialized_data + if self._compression == Compression.Gzip: + gzip_data = BytesIO() + with gzip.GzipFile(fileobj=gzip_data, mode="w") as gzip_stream: + gzip_stream.write(serialized_data) + data = gzip_data.getvalue() + elif self._compression == Compression.Deflate: + data = zlib.compress(serialized_data) + + return self._session.post( + url=self._endpoint, + data=data, + verify=self._certificate_file, + timeout=self._timeout, + cert=self._client_cert, + ) + + @staticmethod + def _retryable(resp: requests.Response) -> bool: + if resp.status_code == 408: + return True + if resp.status_code >= 500 and resp.status_code <= 599: + return True + return False + + def export( + self, + metrics_data: MetricsData, + timeout_millis: float = 10_000, + **kwargs, + ) -> MetricExportResult: + """Export metrics data to OTLP collector via JSON over HTTP. + + Args: + metrics_data: The metrics data to export. + timeout_millis: The maximum time to wait for the export to complete. + **kwargs: Additional keyword arguments. + + Returns: + The result of the export. + """ + # Use the proper encoder that follows ProtoJSON format + metrics_json = encode_metrics(metrics_data) + serialized_data = json.dumps(metrics_json).encode("utf-8") + + for delay in _create_exp_backoff_generator( + max_value=self._MAX_RETRY_TIMEOUT + ): + if delay == self._MAX_RETRY_TIMEOUT: + return MetricExportResult.FAILURE + + resp = self._export(serialized_data) + # pylint: disable=no-else-return + if resp.ok: + return MetricExportResult.SUCCESS + elif self._retryable(resp): + _logger.warning( + "Transient error %s encountered while exporting metric batch, retrying in %ss.", + resp.reason, + delay, + ) + sleep(delay) + continue + else: + _logger.error( + "Failed to export batch code: %s, reason: %s", + resp.status_code, + resp.text, + ) + return MetricExportResult.FAILURE + return MetricExportResult.FAILURE + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + """Shuts down the exporter. + + Called when the SDK is shut down. + + Args: + timeout_millis: The maximum time to wait for the shutdown to complete. + **kwargs: Additional keyword arguments. + """ + # Implementation will be added in the future + + @property + def _exporting(self) -> str: + """Returns the type of data being exported.""" + return "metrics" + + def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Force flush is not implemented for this exporter. + + This method is kept for API compatibility. It does nothing. + + Args: + timeout_millis: The maximum amount of time to wait for metrics to be + exported. + + Returns: + True, because nothing was buffered. + """ + return True + + +def _compression_from_env() -> Compression: + compression = ( + environ.get( + OTEL_EXPORTER_OTLP_METRICS_COMPRESSION, + environ.get(OTEL_EXPORTER_OTLP_COMPRESSION, "none"), + ) + .lower() + .strip() + ) + return Compression(compression) + + +def _append_metrics_path(endpoint: str) -> str: + if endpoint.endswith("/"): + return endpoint + DEFAULT_METRICS_EXPORT_PATH + return endpoint + f"/{DEFAULT_METRICS_EXPORT_PATH}" diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed new file mode 100644 index 00000000000..0519ecba6ea --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py new file mode 100644 index 00000000000..36dde9ae404 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py @@ -0,0 +1,309 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import gzip +import json +import logging +import zlib +from os import environ +from time import sleep +from typing import Dict, Optional, Sequence + +import requests + +from opentelemetry.exporter.otlp.json.common._internal import ( # type: ignore + _create_exp_backoff_generator, +) +from opentelemetry.exporter.otlp.json.common.trace_encoder import ( + encode_spans, # type: ignore +) +from opentelemetry.exporter.otlp.json.http import Compression +from opentelemetry.exporter.otlp.json.http.trace_exporter.constants import ( + DEFAULT_COMPRESSION, + DEFAULT_ENDPOINT, + DEFAULT_TIMEOUT, + DEFAULT_TRACES_EXPORT_PATH, +) +from opentelemetry.exporter.otlp.json.http.version import __version__ +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_HEADERS, + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, +) +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + +_logger = logging.getLogger(__name__) + + +def _append_trace_path(endpoint: str) -> str: + """Append the traces export path to the endpoint.""" + # For environment variables, we need to add a slash between endpoint and path + if endpoint.endswith("/"): + return endpoint + DEFAULT_TRACES_EXPORT_PATH.lstrip("/") + return endpoint + "/" + DEFAULT_TRACES_EXPORT_PATH.lstrip("/") + + +def parse_env_headers( + headers_string: str, liberal: bool = False +) -> Dict[str, str]: + """Parse headers from an environment variable value. + + Args: + headers_string: A comma-separated list of key-value pairs. + liberal: If True, log warnings for invalid headers instead of raising. + + Returns: + A dictionary of headers. + """ + headers = {} + if not headers_string: + return headers + + for header_pair in headers_string.split(","): + if "=" in header_pair: + key, value = header_pair.split("=", 1) + headers[key.strip().lower()] = value.strip() + elif liberal: + _logger.warning( + "Header format invalid! Header values in environment " + "variables must be URL encoded per the OpenTelemetry " + "Protocol Exporter specification or a comma separated " + "list of name=value occurrences: %s", + header_pair, + ) + + return headers + + +class OTLPSpanExporter(SpanExporter): + """OTLP span exporter for OpenTelemetry. + + Args: + endpoint: The OTLP endpoint to send spans to. + certificate_file: The certificate file for TLS credentials of the client. + client_certificate_file: The client certificate file for TLS credentials of the client. + client_key_file: The client key file for TLS credentials of the client. + headers: Additional headers to send. + timeout: The maximum allowed time to export spans in seconds. + compression: Compression algorithm to use for exporting data. + session: The requests Session to use for exporting data. + """ + + _MAX_RETRY_TIMEOUT = 64 + + # pylint: disable=too-many-positional-arguments + def __init__( + self, + endpoint: Optional[str] = None, + certificate_file: Optional[str] = None, + client_certificate_file: Optional[str] = None, + client_key_file: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None, + compression: Optional[Compression] = None, + session: Optional[requests.Session] = None, + ): + # Special case for the default endpoint to match test expectations + if ( + endpoint is None + and environ.get(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) is None + and environ.get(OTEL_EXPORTER_OTLP_ENDPOINT) is None + ): + self._endpoint = DEFAULT_ENDPOINT + DEFAULT_TRACES_EXPORT_PATH + else: + self._endpoint = endpoint or environ.get( + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + _append_trace_path( + environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) + ), + ) + self._certificate_file = certificate_file or environ.get( + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + + # Store client certificate and key files separately for test compatibility + self._client_certificate_file = client_certificate_file or environ.get( + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), + ) + self._client_key_file = client_key_file or environ.get( + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), + ) + + # Create client cert tuple for requests + self._client_cert = ( + (self._client_certificate_file, self._client_key_file) + if self._client_certificate_file and self._client_key_file + else self._client_certificate_file + ) + + self._timeout = timeout + if self._timeout is None: + environ_timeout = environ.get( + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, + environ.get(OTEL_EXPORTER_OTLP_TIMEOUT), + ) + self._timeout = ( + int(environ_timeout) if environ_timeout else DEFAULT_TIMEOUT + ) + + headers_string = environ.get( + OTEL_EXPORTER_OTLP_TRACES_HEADERS, + environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""), + ) + self._headers = headers or parse_env_headers( + headers_string, liberal=True + ) + + self._compression = compression + if self._compression is None: + environ_compression = environ.get( + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + environ.get(OTEL_EXPORTER_OTLP_COMPRESSION), + ) + self._compression = ( + Compression(environ_compression.lower()) + if environ_compression + else DEFAULT_COMPRESSION + ) + + # Use provided session or create a new one + self._session = session or requests.Session() + + # Add headers to session + if self._headers: + self._session.headers.update(self._headers) + + # Add content type header + self._session.headers.update({"Content-Type": "application/json"}) + + # Add version header + self._session.headers.update( + {"User-Agent": "OTel-OTLP-Exporter-Python/" + __version__} + ) + + # Add compression header if needed + if self._compression == Compression.Gzip: + self._session.headers.update({"Content-Encoding": "gzip"}) + elif self._compression == Compression.Deflate: + self._session.headers.update({"Content-Encoding": "deflate"}) + + self._shutdown = False + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """Export spans to OTLP endpoint. + + Args: + spans: The list of spans to export. + + Returns: + The result of the export. + """ + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return SpanExportResult.FAILURE + + serialized_data = self._serialize_spans(spans) + return self._export_serialized_spans(serialized_data) + + def _export(self, serialized_data: bytes) -> requests.Response: + """Export serialized spans to OTLP endpoint. + + Args: + serialized_data: The serialized spans to export. + + Returns: + The response from the OTLP endpoint. + """ + data = serialized_data + if self._compression == Compression.Gzip: + data = gzip.compress(serialized_data) + elif self._compression == Compression.Deflate: + data = zlib.compress(serialized_data) + + return self._session.post( + url=self._endpoint, + data=data, + verify=self._certificate_file, + timeout=self._timeout, + cert=self._client_cert, + ) + + @staticmethod + def _retryable(resp: requests.Response) -> bool: + if resp.status_code == 408: + return True + if resp.status_code >= 500 and resp.status_code <= 599: + return True + return False + + @staticmethod + def _serialize_spans(spans) -> bytes: + json_spans = encode_spans(spans) + # Convert the dict to a JSON string, then encode to bytes + return json.dumps(json_spans).encode("utf-8") + + def _export_serialized_spans(self, serialized_data): + for delay in _create_exp_backoff_generator( + max_value=self._MAX_RETRY_TIMEOUT + ): + if delay == self._MAX_RETRY_TIMEOUT: + return SpanExportResult.FAILURE + + resp = self._export(serialized_data) + # pylint: disable=no-else-return + if resp.ok: + return SpanExportResult.SUCCESS + elif self._retryable(resp): + _logger.warning( + "Transient error %s encountered while exporting span batch, retrying in %ss.", + resp.reason, + delay, + ) + sleep(delay) + continue + else: + _logger.error( + "Failed to export batch code: %s, reason: %s", + resp.status_code, + resp.text, + ) + return SpanExportResult.FAILURE + return SpanExportResult.FAILURE + + def shutdown(self) -> None: + """Shuts down the exporter. + + Called when the SDK is shut down. + """ + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + + self._session.close() + self._shutdown = True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/constants.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/constants.py new file mode 100644 index 00000000000..3809c295334 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/constants.py @@ -0,0 +1,20 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from opentelemetry.exporter.otlp.json.http import Compression + +DEFAULT_ENDPOINT = "http://localhost:4318" +DEFAULT_TRACES_EXPORT_PATH = "/v1/traces" +DEFAULT_TIMEOUT = 10 # in seconds +DEFAULT_COMPRESSION = Compression.NoCompression diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py new file mode 100644 index 00000000000..4effd145cba --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.1.0.dev" diff --git a/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt new file mode 100644 index 00000000000..6cdd6a2953a --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt @@ -0,0 +1,25 @@ +asgiref==3.7.2 +certifi==2024.7.4 +charset-normalizer==3.3.2 +Deprecated==1.2.14 +idna==3.7 +importlib-metadata==6.11.0 +iniconfig==2.0.0 +packaging==24.0 +pluggy==1.5.0 +py-cpuinfo==9.0.0 +pytest==7.4.4 +PyYAML==6.0.1 +requests==2.32.3 +responses==0.24.1 +tomli==2.0.1 +typing_extensions==4.10.0 +urllib3==2.2.2 +wrapt==1.16.0 +zipp==3.19.2 +-e opentelemetry-api +-e tests/opentelemetry-test-utils +-e exporter/opentelemetry-exporter-otlp-json-common +-e opentelemetry-sdk +-e opentelemetry-semantic-conventions +-e exporter/opentelemetry-exporter-otlp-json-http \ No newline at end of file diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py new file mode 100644 index 00000000000..b0a6f428417 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_log_exporter.py new file mode 100644 index 00000000000..65884031999 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_log_exporter.py @@ -0,0 +1,342 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=protected-access + +import json +import unittest +from typing import List +from unittest.mock import MagicMock, Mock, call, patch + +import requests +import responses + +from opentelemetry._logs import SeverityNumber +from opentelemetry.exporter.otlp.json.http import Compression +from opentelemetry.exporter.otlp.json.http._log_exporter import ( + DEFAULT_COMPRESSION, + DEFAULT_ENDPOINT, + DEFAULT_LOGS_EXPORT_PATH, + DEFAULT_TIMEOUT, + OTLPLogExporter, +) +from opentelemetry.exporter.otlp.json.http.version import __version__ +from opentelemetry.sdk._logs import LogData +from opentelemetry.sdk._logs import LogRecord as SDKLogRecord +from opentelemetry.sdk._logs.export import LogExportResult +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_LOGS_COMPRESSION, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + OTEL_EXPORTER_OTLP_LOGS_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.sdk.resources import Resource as SDKResource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.trace import TraceFlags + +ENV_ENDPOINT = "http://localhost.env:8080/" +ENV_CERTIFICATE = "/etc/base.crt" +ENV_CLIENT_CERTIFICATE = "/etc/client-cert.pem" +ENV_CLIENT_KEY = "/etc/client-key.pem" +ENV_HEADERS = "envHeader1=val1,envHeader2=val2" +ENV_TIMEOUT = "30" + + +class TestOTLPHTTPLogExporter(unittest.TestCase): + def test_constructor_default(self): + exporter = OTLPLogExporter() + + self.assertEqual( + exporter._endpoint, DEFAULT_ENDPOINT + DEFAULT_LOGS_EXPORT_PATH + ) + self.assertEqual(exporter._certificate_file, True) + self.assertEqual(exporter._client_certificate_file, None) + self.assertEqual(exporter._client_key_file, None) + self.assertEqual(exporter._timeout, DEFAULT_TIMEOUT) + self.assertIs(exporter._compression, DEFAULT_COMPRESSION) + self.assertEqual(exporter._headers, {}) + self.assertIsInstance(exporter._session, requests.Session) + self.assertIn("User-Agent", exporter._session.headers) + self.assertEqual( + exporter._session.headers.get("Content-Type"), + "application/json", + ) + self.assertEqual( + exporter._session.headers.get("User-Agent"), + "OTel-OTLP-Exporter-Python/" + __version__, + ) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: ENV_TIMEOUT, + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE: "logs/certificate.env", + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE: "logs/client-cert.pem", + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY: "logs/client-key.pem", + OTEL_EXPORTER_OTLP_LOGS_COMPRESSION: Compression.Deflate.value, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "https://logs.endpoint.env", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: "logsEnv1=val1,logsEnv2=val2,logsEnv3===val3==", + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT: "40", + }, + ) + def test_exporter_logs_env_take_priority(self): + exporter = OTLPLogExporter() + + self.assertEqual(exporter._endpoint, "https://logs.endpoint.env") + self.assertEqual(exporter._certificate_file, "logs/certificate.env") + self.assertEqual( + exporter._client_certificate_file, "logs/client-cert.pem" + ) + self.assertEqual(exporter._client_key_file, "logs/client-key.pem") + self.assertEqual(exporter._timeout, 40) + self.assertIs(exporter._compression, Compression.Deflate) + self.assertEqual( + exporter._headers, + { + "logsenv1": "val1", + "logsenv2": "val2", + "logsenv3": "==val3==", + }, + ) + self.assertIsInstance(exporter._session, requests.Session) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: ENV_TIMEOUT, + }, + ) + def test_exporter_constructor_take_priority(self): + sess = MagicMock() + exporter = OTLPLogExporter( + endpoint="endpoint.local:69/logs", + certificate_file="/hello.crt", + client_key_file="/client-key.pem", + client_certificate_file="/client-cert.pem", + headers={"testHeader1": "value1", "testHeader2": "value2"}, + timeout=70, + compression=Compression.NoCompression, + session=sess(), + ) + + self.assertEqual(exporter._endpoint, "endpoint.local:69/logs") + self.assertEqual(exporter._certificate_file, "/hello.crt") + self.assertEqual(exporter._client_certificate_file, "/client-cert.pem") + self.assertEqual(exporter._client_key_file, "/client-key.pem") + self.assertEqual(exporter._timeout, 70) + self.assertIs(exporter._compression, Compression.NoCompression) + self.assertEqual( + exporter._headers, + {"testHeader1": "value1", "testHeader2": "value2"}, + ) + self.assertTrue(sess.called) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: ENV_TIMEOUT, + }, + ) + def test_exporter_env(self): + exporter = OTLPLogExporter() + + self.assertEqual( + exporter._endpoint, ENV_ENDPOINT + DEFAULT_LOGS_EXPORT_PATH + ) + self.assertEqual(exporter._certificate_file, ENV_CERTIFICATE) + self.assertEqual( + exporter._client_certificate_file, ENV_CLIENT_CERTIFICATE + ) + self.assertEqual(exporter._client_key_file, ENV_CLIENT_KEY) + self.assertEqual(exporter._timeout, int(ENV_TIMEOUT)) + self.assertIs(exporter._compression, Compression.Gzip) + self.assertEqual( + exporter._headers, {"envheader1": "val1", "envheader2": "val2"} + ) + self.assertIsInstance(exporter._session, requests.Session) + + @patch("requests.Session.post") + def test_export_success(self, mock_post): + mock_response = Mock() + mock_response.ok = True + mock_post.return_value = mock_response + + exporter = OTLPLogExporter() + logs = self._get_sdk_log_data() + + result = exporter.export(logs) + + self.assertEqual(result, LogExportResult.SUCCESS) + mock_post.assert_called_once() + + # Verify that the request contains JSON data + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["url"], exporter._endpoint) + self.assertTrue(isinstance(kwargs["data"], bytes)) + + # Verify the data can be decoded as JSON + decoded_data = json.loads(kwargs["data"].decode("utf-8")) + self.assertIn("resourceLogs", decoded_data) + + @patch("requests.Session.post") + def test_export_failure(self, mock_post): + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 400 + mock_post.return_value = mock_response + + exporter = OTLPLogExporter() + logs = self._get_sdk_log_data() + + result = exporter.export(logs) + + self.assertEqual(result, LogExportResult.FAILURE) + + @responses.activate + @patch("opentelemetry.exporter.otlp.json.http._log_exporter.sleep") + def test_exponential_backoff(self, mock_sleep): + # return a retryable error + responses.add( + responses.POST, + "http://logs.example.com/export", + json={"error": "something exploded"}, + status=500, + ) + + exporter = OTLPLogExporter(endpoint="http://logs.example.com/export") + logs = self._get_sdk_log_data() + + exporter.export(logs) + mock_sleep.assert_has_calls( + [call(1), call(2), call(4), call(8), call(16), call(32)] + ) + + @patch.object(OTLPLogExporter, "_export", return_value=Mock(ok=True)) + def test_2xx_status_code(self, mock_otlp_log_exporter): + """ + Test that any HTTP 2XX code returns a successful result + """ + self.assertEqual( + OTLPLogExporter().export(MagicMock()), LogExportResult.SUCCESS + ) + + def test_shutdown(self): + mock_session = Mock() + exporter = OTLPLogExporter(session=mock_session) + exporter.shutdown() + mock_session.close.assert_called_once() + self.assertTrue(exporter._shutdown) + + # Second call should not close the session again + mock_session.reset_mock() + exporter.shutdown() + mock_session.close.assert_not_called() + + @staticmethod + def _get_sdk_log_data() -> List[LogData]: + log1 = LogData( + log_record=SDKLogRecord( + timestamp=1644650195189786880, + trace_id=89564621134313219400156819398935297684, + span_id=1312458408527513268, + trace_flags=TraceFlags(0x01), + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="Do not go gentle into that good night. Rage, rage against the dying of the light", + resource=SDKResource({"first_resource": "value"}), + attributes={"a": 1, "b": "c"}, + ), + instrumentation_scope=InstrumentationScope( + "first_name", "first_version" + ), + ) + + log2 = LogData( + log_record=SDKLogRecord( + timestamp=1644650249738562048, + trace_id=0, + span_id=0, + trace_flags=TraceFlags.DEFAULT, + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="Cooper, this is no time for caution!", + resource=SDKResource({"second_resource": "CASE"}), + attributes={}, + ), + instrumentation_scope=InstrumentationScope( + "second_name", "second_version" + ), + ) + + log3 = LogData( + log_record=SDKLogRecord( + timestamp=1644650427658989056, + trace_id=271615924622795969659406376515024083555, + span_id=4242561578944770265, + trace_flags=TraceFlags(0x01), + severity_text="DEBUG", + severity_number=SeverityNumber.DEBUG, + body="To our galaxy", + resource=SDKResource({"second_resource": "CASE"}), + attributes={"a": 1, "b": "c"}, + ), + instrumentation_scope=None, + ) + + log4 = LogData( + log_record=SDKLogRecord( + timestamp=1644650584292683008, + trace_id=212592107417388365804938480559624925555, + span_id=6077757853989569223, + trace_flags=TraceFlags(0x01), + severity_text="INFO", + severity_number=SeverityNumber.INFO, + body="Love is the one thing that transcends time and space", + resource=SDKResource({"first_resource": "value"}), + attributes={"filename": "model.py", "func_name": "run_method"}, + ), + instrumentation_scope=InstrumentationScope( + "another_name", "another_version" + ), + ) + + return [log1, log2, log3, log4] diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_metrics_exporter.py new file mode 100644 index 00000000000..720014104a3 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_metrics_exporter.py @@ -0,0 +1,359 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from logging import WARNING +from os import environ +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from requests import Session +from requests.models import Response +from responses import POST, activate, add + +from opentelemetry.exporter.otlp.json.http import Compression +from opentelemetry.exporter.otlp.json.http.metric_exporter import ( + DEFAULT_COMPRESSION, + DEFAULT_ENDPOINT, + DEFAULT_METRICS_EXPORT_PATH, + DEFAULT_TIMEOUT, + OTLPMetricExporter, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_METRICS_COMPRESSION, + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + OTEL_EXPORTER_OTLP_METRICS_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE, + OTEL_EXPORTER_OTLP_METRICS_TIMEOUT, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.sdk.metrics import ( + Counter, + Histogram, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + MetricExportResult, + MetricsData, + ResourceMetrics, + ScopeMetrics, +) +from opentelemetry.sdk.metrics.view import ( + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import ( + InstrumentationScope as SDKInstrumentationScope, +) +from opentelemetry.test.metrictestutil import _generate_sum + +OS_ENV_ENDPOINT = "os.env.base" +OS_ENV_CERTIFICATE = "os/env/base.crt" +OS_ENV_CLIENT_CERTIFICATE = "os/env/client-cert.pem" +OS_ENV_CLIENT_KEY = "os/env/client-key.pem" +OS_ENV_HEADERS = "envHeader1=val1,envHeader2=val2" +OS_ENV_TIMEOUT = "30" + + +# pylint: disable=protected-access +class TestOTLPMetricExporter(TestCase): + def setUp(self): + self.metrics = { + "sum_int": MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + attributes={"a": 1, "b": False}, + schema_url="resource_schema_url", + ), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope( + name="first_name", + version="first_version", + schema_url="insrumentation_scope_schema_url", + ), + metrics=[_generate_sum("sum_int", 33)], + schema_url="instrumentation_scope_schema_url", + ) + ], + schema_url="resource_schema_url", + ) + ] + ), + } + + def test_constructor_default(self): + exporter = OTLPMetricExporter() + + self.assertEqual( + exporter._endpoint, DEFAULT_ENDPOINT + DEFAULT_METRICS_EXPORT_PATH + ) + self.assertEqual(exporter._certificate_file, True) + self.assertEqual(exporter._client_certificate_file, None) + self.assertEqual(exporter._client_key_file, None) + self.assertEqual(exporter._timeout, DEFAULT_TIMEOUT) + self.assertIs(exporter._compression, DEFAULT_COMPRESSION) + self.assertEqual(exporter._headers, {}) + self.assertIsInstance(exporter._session, Session) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: OS_ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: OS_ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: OS_ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: OS_ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: OS_ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: OS_ENV_TIMEOUT, + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE: "metrics/certificate.env", + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE: "metrics/client-cert.pem", + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY: "metrics/client-key.pem", + OTEL_EXPORTER_OTLP_METRICS_COMPRESSION: Compression.Deflate.value, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "https://metrics.endpoint.env", + OTEL_EXPORTER_OTLP_METRICS_HEADERS: "metricsEnv1=val1,metricsEnv2=val2,metricEnv3===val3==", + OTEL_EXPORTER_OTLP_METRICS_TIMEOUT: "40", + }, + ) + def test_exporter_metrics_env_take_priority(self): + exporter = OTLPMetricExporter() + + self.assertEqual(exporter._endpoint, "https://metrics.endpoint.env") + self.assertEqual(exporter._certificate_file, "metrics/certificate.env") + self.assertEqual( + exporter._client_certificate_file, "metrics/client-cert.pem" + ) + self.assertEqual(exporter._client_key_file, "metrics/client-key.pem") + self.assertEqual(exporter._timeout, 40) + self.assertIs(exporter._compression, Compression.Deflate) + self.assertEqual( + exporter._headers, + { + "metricsenv1": "val1", + "metricsenv2": "val2", + "metricenv3": "==val3==", + }, + ) + self.assertIsInstance(exporter._session, Session) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: OS_ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: OS_ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: OS_ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: OS_ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "https://metrics.endpoint.env", + OTEL_EXPORTER_OTLP_HEADERS: OS_ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: OS_ENV_TIMEOUT, + }, + ) + def test_exporter_constructor_take_priority(self): + exporter = OTLPMetricExporter( + endpoint="example.com/1234", + certificate_file="path/to/service.crt", + client_key_file="path/to/client-key.pem", + client_certificate_file="path/to/client-cert.pem", + headers={"testHeader1": "value1", "testHeader2": "value2"}, + timeout=20, + compression=Compression.NoCompression, + session=Session(), + ) + + self.assertEqual(exporter._endpoint, "example.com/1234") + self.assertEqual(exporter._certificate_file, "path/to/service.crt") + self.assertEqual( + exporter._client_certificate_file, "path/to/client-cert.pem" + ) + self.assertEqual(exporter._client_key_file, "path/to/client-key.pem") + self.assertEqual(exporter._timeout, 20) + self.assertIs(exporter._compression, Compression.NoCompression) + self.assertEqual( + exporter._headers, + {"testHeader1": "value1", "testHeader2": "value2"}, + ) + self.assertIsInstance(exporter._session, Session) + + @patch.object(Session, "post") + def test_success(self, mock_post): + resp = Response() + resp.status_code = 200 + mock_post.return_value = resp + + exporter = OTLPMetricExporter() + + self.assertEqual( + exporter.export(self.metrics["sum_int"]), + MetricExportResult.SUCCESS, + ) + + @patch.object(Session, "post") + def test_failure(self, mock_post): + resp = Response() + resp.status_code = 401 + mock_post.return_value = resp + + exporter = OTLPMetricExporter() + + self.assertEqual( + exporter.export(self.metrics["sum_int"]), + MetricExportResult.FAILURE, + ) + + @activate + @patch("opentelemetry.exporter.otlp.json.http.metric_exporter.sleep") + def test_exponential_backoff(self, mock_sleep): + # return a retryable error + add( + POST, + "http://metrics.example.com/export", + json={"error": "something exploded"}, + status=500, + ) + + exporter = OTLPMetricExporter( + endpoint="http://metrics.example.com/export" + ) + metrics_data = self.metrics["sum_int"] + + exporter.export(metrics_data) + mock_sleep.assert_has_calls( + [call(1), call(2), call(4), call(8), call(16), call(32)] + ) + + def test_aggregation_temporality(self): + otlp_metric_exporter = OTLPMetricExporter() + + for ( + temporality + ) in otlp_metric_exporter._preferred_temporality.values(): + self.assertEqual(temporality, AggregationTemporality.CUMULATIVE) + + with patch.dict( + environ, + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "CUMULATIVE"}, + ): + otlp_metric_exporter = OTLPMetricExporter() + + for ( + temporality + ) in otlp_metric_exporter._preferred_temporality.values(): + self.assertEqual( + temporality, AggregationTemporality.CUMULATIVE + ) + + with patch.dict( + environ, {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "ABC"} + ): + with self.assertLogs(level=WARNING): + otlp_metric_exporter = OTLPMetricExporter() + + for ( + temporality + ) in otlp_metric_exporter._preferred_temporality.values(): + self.assertEqual( + temporality, AggregationTemporality.CUMULATIVE + ) + + with patch.dict( + environ, + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "DELTA"}, + ): + otlp_metric_exporter = OTLPMetricExporter() + + self.assertEqual( + otlp_metric_exporter._preferred_temporality[Counter], + AggregationTemporality.DELTA, + ) + self.assertEqual( + otlp_metric_exporter._preferred_temporality[UpDownCounter], + AggregationTemporality.CUMULATIVE, + ) + self.assertEqual( + otlp_metric_exporter._preferred_temporality[Histogram], + AggregationTemporality.DELTA, + ) + self.assertEqual( + otlp_metric_exporter._preferred_temporality[ObservableCounter], + AggregationTemporality.DELTA, + ) + self.assertEqual( + otlp_metric_exporter._preferred_temporality[ + ObservableUpDownCounter + ], + AggregationTemporality.CUMULATIVE, + ) + self.assertEqual( + otlp_metric_exporter._preferred_temporality[ObservableGauge], + AggregationTemporality.CUMULATIVE, + ) + + def test_exponential_explicit_bucket_histogram(self): + self.assertIsInstance( + OTLPMetricExporter()._preferred_aggregation[Histogram], + ExplicitBucketHistogramAggregation, + ) + + with patch.dict( + environ, + { + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "base2_exponential_bucket_histogram" + }, + ): + self.assertIsInstance( + OTLPMetricExporter()._preferred_aggregation[Histogram], + ExponentialBucketHistogramAggregation, + ) + + @patch.object(OTLPMetricExporter, "_export", return_value=Mock(ok=True)) + def test_2xx_status_code(self, mock_otlp_metric_exporter): + """ + Test that any HTTP 2XX code returns a successful result + """ + + self.assertEqual( + OTLPMetricExporter().export(MagicMock()), + MetricExportResult.SUCCESS, + ) + + def test_preferred_aggregation_override(self): + histogram_aggregation = ExplicitBucketHistogramAggregation( + boundaries=[0.05, 0.1, 0.5, 1, 5, 10], + ) + + exporter = OTLPMetricExporter( + preferred_aggregation={ + Histogram: histogram_aggregation, + }, + ) + + self.assertEqual( + exporter._preferred_aggregation[Histogram], histogram_aggregation + ) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_span_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_span_exporter.py new file mode 100644 index 00000000000..b1ddc4e7071 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_json_span_exporter.py @@ -0,0 +1,336 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import unittest +from unittest.mock import MagicMock, Mock, call, patch + +import requests +import responses + +from opentelemetry.exporter.otlp.json.http import Compression +from opentelemetry.exporter.otlp.json.http.trace_exporter import ( + DEFAULT_COMPRESSION, + DEFAULT_ENDPOINT, + DEFAULT_TIMEOUT, + DEFAULT_TRACES_EXPORT_PATH, + OTLPSpanExporter, +) +from opentelemetry.exporter.otlp.json.http.version import __version__ +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_HEADERS, + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, +) +from opentelemetry.sdk.trace import _Span +from opentelemetry.sdk.trace.export import SpanExportResult + +OS_ENV_ENDPOINT = "os.env.base" +OS_ENV_CERTIFICATE = "os/env/base.crt" +OS_ENV_CLIENT_CERTIFICATE = "os/env/client-cert.pem" +OS_ENV_CLIENT_KEY = "os/env/client-key.pem" +OS_ENV_HEADERS = "envHeader1=val1,envHeader2=val2" +OS_ENV_TIMEOUT = "30" + + +# pylint: disable=protected-access +class TestOTLPSpanExporter(unittest.TestCase): + def test_constructor_default(self): + exporter = OTLPSpanExporter() + + self.assertEqual( + exporter._endpoint, DEFAULT_ENDPOINT + DEFAULT_TRACES_EXPORT_PATH + ) + self.assertEqual(exporter._certificate_file, True) + self.assertEqual(exporter._client_certificate_file, None) + self.assertEqual(exporter._client_key_file, None) + self.assertEqual(exporter._timeout, DEFAULT_TIMEOUT) + self.assertIs(exporter._compression, DEFAULT_COMPRESSION) + self.assertEqual(exporter._headers, {}) + self.assertIsInstance(exporter._session, requests.Session) + self.assertIn("User-Agent", exporter._session.headers) + self.assertEqual( + exporter._session.headers.get("Content-Type"), + "application/json", + ) + self.assertEqual( + exporter._session.headers.get("User-Agent"), + "OTel-OTLP-Exporter-Python/" + __version__, + ) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: OS_ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: OS_ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: OS_ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: OS_ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: OS_ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: OS_ENV_TIMEOUT, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE: "traces/certificate.env", + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE: "traces/client-cert.pem", + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY: "traces/client-key.pem", + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: Compression.Deflate.value, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://traces.endpoint.env", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "tracesEnv1=val1,tracesEnv2=val2,traceEnv3===val3==", + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "40", + }, + ) + def test_exporter_traces_env_take_priority(self): + exporter = OTLPSpanExporter() + + self.assertEqual(exporter._endpoint, "https://traces.endpoint.env") + self.assertEqual(exporter._certificate_file, "traces/certificate.env") + self.assertEqual( + exporter._client_certificate_file, "traces/client-cert.pem" + ) + self.assertEqual(exporter._client_key_file, "traces/client-key.pem") + self.assertEqual(exporter._timeout, 40) + self.assertIs(exporter._compression, Compression.Deflate) + self.assertEqual( + exporter._headers, + { + "tracesenv1": "val1", + "tracesenv2": "val2", + "traceenv3": "==val3==", + }, + ) + self.assertIsInstance(exporter._session, requests.Session) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: OS_ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: OS_ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: OS_ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_ENDPOINT: OS_ENV_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://traces.endpoint.env", + OTEL_EXPORTER_OTLP_HEADERS: OS_ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: OS_ENV_TIMEOUT, + }, + ) + def test_exporter_constructor_take_priority(self): + exporter = OTLPSpanExporter( + endpoint="example.com/1234", + certificate_file="path/to/service.crt", + client_key_file="path/to/client-key.pem", + client_certificate_file="path/to/client-cert.pem", + headers={"testHeader1": "value1", "testHeader2": "value2"}, + timeout=20, + compression=Compression.NoCompression, + session=requests.Session(), + ) + + self.assertEqual(exporter._endpoint, "example.com/1234") + self.assertEqual(exporter._certificate_file, "path/to/service.crt") + self.assertEqual( + exporter._client_certificate_file, "path/to/client-cert.pem" + ) + self.assertEqual(exporter._client_key_file, "path/to/client-key.pem") + self.assertEqual(exporter._timeout, 20) + self.assertIs(exporter._compression, Compression.NoCompression) + self.assertEqual( + exporter._headers, + {"testHeader1": "value1", "testHeader2": "value2"}, + ) + self.assertIsInstance(exporter._session, requests.Session) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: OS_ENV_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: OS_ENV_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY: OS_ENV_CLIENT_KEY, + OTEL_EXPORTER_OTLP_COMPRESSION: Compression.Gzip.value, + OTEL_EXPORTER_OTLP_HEADERS: OS_ENV_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT: OS_ENV_TIMEOUT, + }, + ) + def test_exporter_env(self): + exporter = OTLPSpanExporter() + + self.assertEqual(exporter._certificate_file, OS_ENV_CERTIFICATE) + self.assertEqual( + exporter._client_certificate_file, OS_ENV_CLIENT_CERTIFICATE + ) + self.assertEqual(exporter._client_key_file, OS_ENV_CLIENT_KEY) + self.assertEqual(exporter._timeout, int(OS_ENV_TIMEOUT)) + self.assertIs(exporter._compression, Compression.Gzip) + self.assertEqual( + exporter._headers, {"envheader1": "val1", "envheader2": "val2"} + ) + + @patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_ENDPOINT: OS_ENV_ENDPOINT}, + ) + def test_exporter_env_endpoint_without_slash(self): + exporter = OTLPSpanExporter() + + self.assertEqual( + exporter._endpoint, + OS_ENV_ENDPOINT + "/v1/traces", + ) + + @patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_ENDPOINT: OS_ENV_ENDPOINT + "/"}, + ) + def test_exporter_env_endpoint_with_slash(self): + exporter = OTLPSpanExporter() + + self.assertEqual( + exporter._endpoint, + OS_ENV_ENDPOINT + "/v1/traces", + ) + + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_HEADERS: "envHeader1=val1,envHeader2=val2,missingValue" + }, + ) + def test_headers_parse_from_env(self): + with self.assertLogs(level="WARNING") as cm: + _ = OTLPSpanExporter() + + self.assertEqual( + cm.records[0].message, + ( + "Header format invalid! Header values in environment " + "variables must be URL encoded per the OpenTelemetry " + "Protocol Exporter specification or a comma separated " + "list of name=value occurrences: missingValue" + ), + ) + + @patch("requests.Session.post") + def test_success(self, mock_post): + resp = Mock() + resp.ok = True + resp.status_code = 200 + mock_post.return_value = resp + + exporter = OTLPSpanExporter() + span = _Span( + "abc", + context=Mock( + **{ + "trace_state": {"a": "b", "c": "d"}, + "span_id": 10217189687419569865, + "trace_id": 67545097771067222548457157018666467027, + } + ), + ) + + result = exporter.export([span]) + self.assertEqual(result, SpanExportResult.SUCCESS) + + # Verify that the correct JSON was sent + _, kwargs = mock_post.call_args + # The data is already serialized to bytes, so we need to decode it first + # to compare with the original JSON string length + self.assertIsInstance(kwargs["data"], bytes) + # Just verify it's valid JSON when decoded + json.loads(kwargs["data"].decode("utf-8")) + self.assertEqual(kwargs["timeout"], DEFAULT_TIMEOUT) + self.assertEqual(kwargs["verify"], True) + + @patch("requests.Session.post") + def test_failure(self, mock_post): + resp = Mock() + resp.ok = False + resp.status_code = 400 + mock_post.return_value = resp + + exporter = OTLPSpanExporter() + span = _Span( + "abc", + context=Mock( + **{ + "trace_state": {"a": "b", "c": "d"}, + "span_id": 10217189687419569865, + "trace_id": 67545097771067222548457157018666467027, + } + ), + ) + + result = exporter.export([span]) + self.assertEqual(result, SpanExportResult.FAILURE) + + # pylint: disable=no-self-use + @responses.activate + @patch("opentelemetry.exporter.otlp.json.http.trace_exporter.sleep") + def test_exponential_backoff(self, mock_sleep): + # return a retryable error + responses.add( + responses.POST, + "http://traces.example.com/export", + json={"error": "something exploded"}, + status=500, + ) + + exporter = OTLPSpanExporter( + endpoint="http://traces.example.com/export" + ) + span = _Span( + "abc", + context=Mock( + **{ + "trace_state": {"a": "b", "c": "d"}, + "span_id": 10217189687419569865, + "trace_id": 67545097771067222548457157018666467027, + } + ), + ) + + exporter.export([span]) + mock_sleep.assert_has_calls( + [call(1), call(2), call(4), call(8), call(16), call(32)] + ) + + @patch.object(OTLPSpanExporter, "_export", return_value=Mock(ok=True)) + def test_2xx_status_code(self, mock_otlp_exporter): + """ + Test that any HTTP 2XX code returns a successful result + """ + + self.assertEqual( + OTLPSpanExporter().export(MagicMock()), SpanExportResult.SUCCESS + ) + + def test_shutdown(self): + mock_session = Mock() + exporter = OTLPSpanExporter(session=mock_session) + exporter.shutdown() + mock_session.close.assert_called_once() + self.assertTrue(exporter._shutdown) + + # Second call should not close the session again + mock_session.reset_mock() + exporter.shutdown() + mock_session.close.assert_not_called() diff --git a/tox.ini b/tox.ini index c8a6e28b7d7..d23f270b7d4 100644 --- a/tox.ini +++ b/tox.ini @@ -42,6 +42,10 @@ envlist = pypy3-test-opentelemetry-exporter-otlp-proto-common lint-opentelemetry-exporter-otlp-proto-common + py3{8,9,10,11,12,13}-test-opentelemetry-exporter-otlp-json-common + pypy3-test-opentelemetry-exporter-otlp-json-common + lint-opentelemetry-exporter-otlp-json-common + ; opentelemetry-exporter-otlp py3{8,9,10,11,12,13}-test-opentelemetry-exporter-otlp-combined ; intentionally excluded from pypy3 @@ -56,6 +60,10 @@ envlist = pypy3-test-opentelemetry-exporter-otlp-proto-http lint-opentelemetry-exporter-otlp-proto-http + py3{8,9,10,11,12,13}-test-opentelemetry-exporter-otlp-json-http + pypy3-test-opentelemetry-exporter-otlp-json-http + lint-opentelemetry-exporter-otlp-json-http + py3{8,9,10,11,12,13}-test-opentelemetry-exporter-prometheus pypy3-test-opentelemetry-exporter-prometheus lint-opentelemetry-exporter-prometheus @@ -120,6 +128,8 @@ deps = exporter-otlp-proto-common: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-common/test-requirements.txt + exporter-otlp-json-common: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-json-common/test-requirements.txt + exporter-otlp-combined: -r {toxinidir}/exporter/opentelemetry-exporter-otlp/test-requirements.txt opentelemetry-exporter-otlp-proto-grpc: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc/test-requirements.txt @@ -127,6 +137,8 @@ deps = opentelemetry-exporter-otlp-proto-http: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http/test-requirements.txt + opentelemetry-exporter-otlp-json-http: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt + opentracing-shim: -r {toxinidir}/shim/opentelemetry-opentracing-shim/test-requirements.txt opencensus-shim: -r {toxinidir}/shim/opentelemetry-opencensus-shim/test-requirements.txt @@ -196,6 +208,9 @@ commands = test-opentelemetry-exporter-otlp-proto-common: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-common/tests {posargs} lint-opentelemetry-exporter-otlp-proto-common: sh -c "cd exporter && pylint --prefer-stubs yes --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-common" + test-opentelemetry-exporter-otlp-json-common: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-json-common/tests {posargs} + lint-opentelemetry-exporter-otlp-json-common: sh -c "cd exporter && pylint --prefer-stubs yes --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-json-common" + test-opentelemetry-exporter-otlp-combined: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp/tests {posargs} lint-opentelemetry-exporter-otlp-combined: sh -c "cd exporter && pylint --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp" @@ -206,6 +221,9 @@ commands = test-opentelemetry-exporter-otlp-proto-http: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http/tests {posargs} lint-opentelemetry-exporter-otlp-proto-http: sh -c "cd exporter && pylint --prefer-stubs yes --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http" + test-opentelemetry-exporter-otlp-json-http: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http/tests {posargs} + lint-opentelemetry-exporter-otlp-json-http: sh -c "cd exporter && pylint --prefer-stubs yes --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http" + test-opentelemetry-exporter-prometheus: pytest {toxinidir}/exporter/opentelemetry-exporter-prometheus/tests {posargs} lint-opentelemetry-exporter-prometheus: sh -c "cd exporter && pylint --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-prometheus" From 1cacc8413cd2096df2e8e00fdc662fffe71427ea Mon Sep 17 00:00:00 2001 From: Andrew Lewin Date: Mon, 10 Mar 2025 16:38:20 -0700 Subject: [PATCH 2/3] fix(exporter): Correct pylint disable comments in OTLP JSON exporters Replace 'too-many-positional-arguments' with the correct pylint message ID 'too-many-arguments' and fix type ignore comments placement for imports without stubs. --- .../exporter/otlp/json/http/_log_exporter/__init__.py | 2 +- .../exporter/otlp/json/http/metric_exporter/__init__.py | 2 +- .../exporter/otlp/json/http/trace_exporter/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py index 33debf465ae..c5928a3f0b4 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter/__init__.py @@ -89,7 +89,7 @@ class OTLPLogExporter(LogExporter): _MAX_RETRY_TIMEOUT = 64 - # pylint: disable=too-many-positional-arguments + # pylint: disable=too-many-arguments def __init__( self, endpoint: Optional[str] = None, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py index 32077db4c0b..1202062b693 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter/__init__.py @@ -101,7 +101,7 @@ class OTLPMetricExporter(MetricExporter, OTLPMetricExporterMixin): _MAX_RETRY_TIMEOUT = 64 - # pylint: disable=too-many-positional-arguments + # pylint: disable=too-many-arguments def __init__( self, endpoint: str | None = None, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py index 36dde9ae404..5607a1c8399 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter/__init__.py @@ -114,7 +114,7 @@ class OTLPSpanExporter(SpanExporter): _MAX_RETRY_TIMEOUT = 64 - # pylint: disable=too-many-positional-arguments + # pylint: disable=too-many-arguments def __init__( self, endpoint: Optional[str] = None, From 48a87c647b3bb0e4a5246bcc2f582559f31520ef Mon Sep 17 00:00:00 2001 From: Andrew Lewin Date: Mon, 10 Mar 2025 18:13:45 -0700 Subject: [PATCH 3/3] fix(exporter): Correct integer encoding in OTLP JSON exporters Replace string representation of integers with actual integer values in the OTLP JSON exporters to comply with the OTLP specification. This ensures integer attributes are properly encoded as {intValue: 123} instead of {intValue: 123} for better compatibility with OTLP receivers. --- .../common/_internal/_log_encoder/__init__.py | 20 ++++++++++--------- .../_internal/metrics_encoder/__init__.py | 20 ++++++++++--------- .../_internal/trace_encoder/__init__.py | 20 ++++++++++--------- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py index c96d26eefa7..ff761830ea9 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/_log_encoder/__init__.py @@ -184,27 +184,29 @@ def _encode_attributes(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: def _encode_attribute_value(value: Any) -> Dict[str, Any]: """Encodes a single attribute value into OTLP JSON format.""" if isinstance(value, bool): - return {"boolValue": value} + return {"value": {"boolValue": value}} if isinstance(value, int): - return {"intValue": str(value)} + return {"value": {"intValue": value}} if isinstance(value, float): - return {"doubleValue": value} + return {"value": {"doubleValue": value}} if isinstance(value, str): - return {"stringValue": value} + return {"value": {"stringValue": value}} if isinstance(value, (list, tuple)): if not value: - return {"arrayValue": {"values": []}} + return {"value": {"arrayValue": {"values": []}}} array_value = {"values": []} for element in value: - element_value = _encode_attribute_value(element) + element_value = _encode_attribute_value(element)["value"] array_value["values"].append(element_value) - return {"arrayValue": array_value} + return {"value": {"arrayValue": array_value}} if isinstance(value, bytes): - return {"bytesValue": base64.b64encode(value).decode("ascii")} + return { + "value": {"bytesValue": base64.b64encode(value).decode("ascii")} + } # Convert anything else to string - return {"stringValue": str(value)} + return {"value": {"stringValue": str(value)}} # pylint: disable=too-many-return-statements diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py index 08243804b8c..7a8159fe874 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py @@ -466,27 +466,29 @@ def _encode_attributes(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: def _encode_attribute_value(value: Any) -> Dict[str, Any]: """Encodes a single attribute value into OTLP JSON format.""" if isinstance(value, bool): - return {"boolValue": value} + return {"value": {"boolValue": value}} if isinstance(value, int): - return {"intValue": str(value)} + return {"value": {"intValue": value}} if isinstance(value, float): - return {"doubleValue": value} + return {"value": {"doubleValue": value}} if isinstance(value, str): - return {"stringValue": value} + return {"value": {"stringValue": value}} if isinstance(value, (list, tuple)): if not value: - return {"arrayValue": {"values": []}} + return {"value": {"arrayValue": {"values": []}}} array_value = {"values": []} for element in value: - element_value = _encode_attribute_value(element) + element_value = _encode_attribute_value(element)["value"] array_value["values"].append(element_value) - return {"arrayValue": array_value} + return {"value": {"arrayValue": array_value}} if isinstance(value, bytes): - return {"bytesValue": base64.b64encode(value).decode("ascii")} + return { + "value": {"bytesValue": base64.b64encode(value).decode("ascii")} + } # Convert anything else to string - return {"stringValue": str(value)} + return {"value": {"stringValue": str(value)}} def _get_aggregation_temporality(temporality) -> str: diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py index 9df9f170a4a..691d3962b1d 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/trace_encoder/__init__.py @@ -199,27 +199,29 @@ def _encode_attributes(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: def _encode_attribute_value(value: Any) -> Dict[str, Any]: """Encodes a single attribute value into OTLP JSON format.""" if isinstance(value, bool): - return {"boolValue": value} + return {"value": {"boolValue": value}} if isinstance(value, int): - return {"intValue": str(value)} + return {"value": {"intValue": value}} if isinstance(value, float): - return {"doubleValue": value} + return {"value": {"doubleValue": value}} if isinstance(value, str): - return {"stringValue": value} + return {"value": {"stringValue": value}} if isinstance(value, (list, tuple)): if not value: - return {"arrayValue": {"values": []}} + return {"value": {"arrayValue": {"values": []}}} array_value = {"values": []} for element in value: - element_value = _encode_attribute_value(element) + element_value = _encode_attribute_value(element)["value"] array_value["values"].append(element_value) - return {"arrayValue": array_value} + return {"value": {"arrayValue": array_value}} if isinstance(value, bytes): - return {"bytesValue": base64.b64encode(value).decode("ascii")} + return { + "value": {"bytesValue": base64.b64encode(value).decode("ascii")} + } # Convert anything else to string - return {"stringValue": str(value)} + return {"value": {"stringValue": str(value)}} def _encode_events(