diff --git a/.changelog/5207.added b/.changelog/5207.added new file mode 100644 index 00000000000..6ee546ae533 --- /dev/null +++ b/.changelog/5207.added @@ -0,0 +1 @@ +`opentelemetry-exporter-otlp-json-file`: Add OTLP JSON File exporter implementation diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f3fe9675be9..84362f0ef6e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -271,6 +271,25 @@ jobs: - name: Run tests run: tox -e lint-opentelemetry-exporter-otlp-combined + lint-opentelemetry-exporter-otlp-json-file: + name: opentelemetry-exporter-otlp-json-file + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-opentelemetry-exporter-otlp-json-file + lint-opentelemetry-exporter-otlp-proto-grpc-latest: name: opentelemetry-exporter-otlp-proto-grpc-latest runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b481fbfa706..f4b12c84e12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1870,6 +1870,139 @@ jobs: - name: Run tests run: tox -e py314-test-opentelemetry-exporter-otlp-combined -- -ra + py310-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-opentelemetry-exporter-otlp-json-file -- -ra + + py311-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-opentelemetry-exporter-otlp-json-file -- -ra + + py312-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-opentelemetry-exporter-otlp-json-file -- -ra + + py313-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-opentelemetry-exporter-otlp-json-file -- -ra + + py314-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-opentelemetry-exporter-otlp-json-file -- -ra + + py314t-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file 3.14t Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14t + uses: actions/setup-python@v5 + with: + python-version: "3.14t" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314t-test-opentelemetry-exporter-otlp-json-file -- -ra + + pypy3-test-opentelemetry-exporter-otlp-json-file_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-file pypy-3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.10 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-file -- -ra + py310-test-opentelemetry-exporter-otlp-proto-grpc-oldest_ubuntu-latest: name: opentelemetry-exporter-otlp-proto-grpc-oldest 3.10 Ubuntu runs-on: ubuntu-latest @@ -5171,6 +5304,153 @@ jobs: - name: Run tests run: tox -e py314-test-opentelemetry-exporter-otlp-combined -- -ra + py310-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file 3.10 Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-opentelemetry-exporter-otlp-json-file -- -ra + + py311-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file 3.11 Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-opentelemetry-exporter-otlp-json-file -- -ra + + py312-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file 3.12 Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-opentelemetry-exporter-otlp-json-file -- -ra + + py313-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file 3.13 Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-opentelemetry-exporter-otlp-json-file -- -ra + + py314-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file 3.14 Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-opentelemetry-exporter-otlp-json-file -- -ra + + py314t-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file 3.14t Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14t + uses: actions/setup-python@v5 + with: + python-version: "3.14t" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314t-test-opentelemetry-exporter-otlp-json-file -- -ra + + pypy3-test-opentelemetry-exporter-otlp-json-file_windows-latest: + name: opentelemetry-exporter-otlp-json-file pypy-3.10 Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git to support long filenames + run: git config --system core.longpaths true + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.10 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-file -- -ra + py310-test-opentelemetry-exporter-otlp-proto-grpc-oldest_windows-latest: name: opentelemetry-exporter-otlp-proto-grpc-oldest 3.10 Windows runs-on: windows-latest diff --git a/eachdist.ini b/eachdist.ini index 09e62be3b22..d4e4afbfd0d 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -35,6 +35,7 @@ packages= opentelemetry-exporter-opencensus opentelemetry-exporter-prometheus opentelemetry-exporter-otlp-json-common + opentelemetry-exporter-otlp-json-file opentelemetry-distro opentelemetry-proto-json opentelemetry-semantic-conventions diff --git a/exporter/opentelemetry-exporter-otlp-json-file/LICENSE b/exporter/opentelemetry-exporter-otlp-json-file/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/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-file/README.rst b/exporter/opentelemetry-exporter-otlp-json-file/README.rst new file mode 100644 index 00000000000..11c8dbef554 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/README.rst @@ -0,0 +1,30 @@ +OpenTelemetry JSON File Exporter +================================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-exporter-otlp-json-file.svg + :target: https://pypi.org/project/opentelemetry-exporter-otlp-json-file/ + +This library exports telemetry as OTLP JSON to a file-like text stream, such +as a file or stdout. + +The exporter writes newline delimited OTLP JSON records for file-based +collection workflows. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-otlp-json-file + + +References +---------- + +* `OpenTelemetry Project `_ +* `OpenTelemetry Protocol File Exporter `_ +* `OTLP Specification `_ +* `OTLP JSON Encoding Specification `_ +* `JSON Lines `_ diff --git a/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml new file mode 100644 index 00000000000..1112e1934be --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-exporter-otlp-json-file" +dynamic = ["version"] +description = "OpenTelemetry JSON File Exporter" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: OpenTelemetry", + "Framework :: OpenTelemetry :: Exporters", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-exporter-otlp-json-common == 0.63b0.dev", + "opentelemetry-sdk ~= 1.42.0.dev", +] + +[project.entry-points.opentelemetry_traces_exporter] +otlp_json_file = "opentelemetry.exporter.otlp.json.file.trace_exporter:FileSpanExporter" + +[project.entry-points.opentelemetry_metrics_exporter] +otlp_json_file = "opentelemetry.exporter.otlp.json.file.metric_exporter:FileMetricExporter" + +[project.entry-points.opentelemetry_logs_exporter] +otlp_json_file = "opentelemetry.exporter.otlp.json.file._log_exporter:FileLogExporter" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/exporter/opentelemetry-exporter-otlp-json-file" +Repository = "https://github.com/open-telemetry/opentelemetry-python" + +[tool.hatch.version] +path = "src/opentelemetry/exporter/otlp/json/file/version/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/__init__.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/__init__.py new file mode 100644 index 00000000000..e57cf4aba95 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_internal.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_internal.py new file mode 100644 index 00000000000..53ae86195c7 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_internal.py @@ -0,0 +1,116 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import json +import logging +from os import environ + +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 +from opentelemetry.sdk.metrics.view import ( + Aggregation, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, +) + +_logger = logging.getLogger(__name__) + + +def _format_line(entry: dict) -> str: + return json.dumps(entry, separators=(",", ":")) + "\n" + + +def _get_temporality( + preferred_temporality: dict[type, AggregationTemporality] | None, +) -> dict[type, AggregationTemporality]: + temporality_preference = ( + environ.get( + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE, + "CUMULATIVE", + ) + .upper() + .strip() + ) + + if temporality_preference == "DELTA": + instrument_class_temporality: dict[type, AggregationTemporality] = { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.DELTA, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + + elif temporality_preference == "LOWMEMORY": + instrument_class_temporality: dict[type, AggregationTemporality] = { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + + else: + if temporality_preference != "CUMULATIVE": + _logger.warning( + "Unrecognized OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE" + " value found: " + "%s, " + "using CUMULATIVE", + temporality_preference, + ) + instrument_class_temporality: dict[type, AggregationTemporality] = { + 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 + + +def _get_aggregation( + preferred_aggregation: dict[type, Aggregation] | None, +) -> dict[type, Aggregation]: + default_histogram_aggregation = environ.get( + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION, + "explicit_bucket_histogram", + ) + + if default_histogram_aggregation == "base2_exponential_bucket_histogram": + instrument_class_aggregation: dict[type, Aggregation] = { + Histogram: ExponentialBucketHistogramAggregation(), + } + else: + if 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, + default_histogram_aggregation, + ) + + instrument_class_aggregation: dict[type, Aggregation] = { + Histogram: ExplicitBucketHistogramAggregation(), + } + + instrument_class_aggregation.update(preferred_aggregation or {}) + return instrument_class_aggregation diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_log_exporter.py new file mode 100644 index 00000000000..7da8b0b6cdb --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_log_exporter.py @@ -0,0 +1,102 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import sys +from collections.abc import Callable, Sequence +from os import PathLike +from typing import IO, overload + +from opentelemetry.exporter.otlp.json.common._log_encoder import encode_logs +from opentelemetry.exporter.otlp.json.file._internal import _format_line +from opentelemetry.sdk._logs import ReadableLogRecord +from opentelemetry.sdk._logs.export import ( + LogRecordExporter, + LogRecordExportResult, +) +from opentelemetry.sdk._shared_internal import DuplicateFilter + +_logger = logging.getLogger(__name__) +# This prevents logs generated when a log fails to be written to generate another log which fails to be written etc. etc. +_logger.addFilter(DuplicateFilter()) + + +class FileLogExporter(LogRecordExporter): + @overload + def __init__( + self, + path: str | PathLike[str], + *, + formatter: Callable[[dict], str] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + stream: IO[str], + formatter: Callable[[dict], str] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + formatter: Callable[[dict], str] | None = None, + ) -> None: ... + + def __init__( + self, + path: str | PathLike[str] | None = None, + *, + stream: IO[str] | None = None, + formatter: Callable[[dict], str] | None = None, + ) -> None: + if path is not None and stream is not None: + raise ValueError("Cannot specify both 'path' and 'stream'") + if path is not None: + self._stream: IO[str] = open( # pylint: disable=consider-using-with + path, "a", encoding="utf-8" + ) + self._owns_stream = True + elif stream is not None: + self._stream = stream + self._owns_stream = False + else: + self._stream = sys.stdout + self._owns_stream = False + self._formatter = formatter or _format_line + self._shutdown = False + + def export( + self, batch: Sequence[ReadableLogRecord] + ) -> LogRecordExportResult: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return LogRecordExportResult.FAILURE + try: + lines = [ + self._formatter(resource_logs.to_dict()) + for resource_logs in encode_logs(batch).resource_logs + ] + self._stream.writelines(lines) + self._stream.flush() + # pylint: disable-next=broad-exception-caught + except Exception as error: + _logger.exception( + "Failed to write log batch to stream: %s: %s", + type(error).__name__, + error, + ) + return LogRecordExportResult.FAILURE + return LogRecordExportResult.SUCCESS + + def shutdown(self) -> None: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._shutdown = True + if self._owns_stream: + self._stream.close() diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py new file mode 100644 index 00000000000..3afc4f1662a --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py @@ -0,0 +1,131 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import logging +import sys +from collections.abc import Callable +from os import PathLike +from typing import IO, overload + +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.exporter.otlp.json.file._internal import ( + _format_line, + _get_aggregation, + _get_temporality, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + MetricExporter, + MetricExportResult, + MetricsData, +) +from opentelemetry.sdk.metrics.view import Aggregation + +_logger = logging.getLogger(__name__) + + +class FileMetricExporter(MetricExporter): + @overload + def __init__( + self, + path: str | PathLike[str], + *, + formatter: Callable[[dict], str] | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + stream: IO[str], + formatter: Callable[[dict], str] | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + formatter: Callable[[dict], str] | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + ) -> None: ... + + def __init__( + self, + path: str | PathLike[str] | None = None, + *, + stream: IO[str] | None = None, + formatter: Callable[[dict], str] | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + ) -> None: + if path is not None and stream is not None: + raise ValueError("Cannot specify both 'path' and 'stream'") + MetricExporter.__init__( + self, + preferred_temporality=_get_temporality(preferred_temporality), + preferred_aggregation=_get_aggregation(preferred_aggregation), + ) + if path is not None: + self._stream: IO[str] = open( # pylint: disable=consider-using-with + path, "a", encoding="utf-8" + ) + self._owns_stream = True + elif stream is not None: + self._stream = stream + self._owns_stream = False + else: + self._stream = sys.stdout + self._owns_stream = False + self._formatter = formatter or _format_line + self._shutdown = False + + def export( + self, + metrics_data: MetricsData, + timeout_millis: float = 10_000, + **kwargs, + ) -> MetricExportResult: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return MetricExportResult.FAILURE + try: + lines = [ + self._formatter(resource_metrics.to_dict()) + for resource_metrics in encode_metrics( + metrics_data + ).resource_metrics + ] + self._stream.writelines(lines) + self._stream.flush() + # pylint: disable-next=broad-exception-caught + except Exception as error: + _logger.exception( + "Failed to write metric batch to stream: %s: %s", + type(error).__name__, + error, + ) + return MetricExportResult.FAILURE + return MetricExportResult.SUCCESS + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._shutdown = True + if self._owns_stream: + self._stream.close() + + def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Nothing is buffered in this exporter, so this method does nothing.""" + return True diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/py.typed b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/trace_exporter.py new file mode 100644 index 00000000000..e19c9814511 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/trace_exporter.py @@ -0,0 +1,99 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import sys +from collections.abc import Callable, Sequence +from os import PathLike +from typing import IO, overload + +from opentelemetry.exporter.otlp.json.common.trace_encoder import encode_spans +from opentelemetry.exporter.otlp.json.file._internal import _format_line +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + +_logger = logging.getLogger(__name__) + + +class FileSpanExporter(SpanExporter): + @overload + def __init__( + self, + path: str | PathLike[str], + *, + formatter: Callable[[dict], str] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + stream: IO[str], + formatter: Callable[[dict], str] | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + formatter: Callable[[dict], str] | None = None, + ) -> None: ... + + def __init__( + self, + path: str | PathLike[str] | None = None, + *, + stream: IO[str] | None = None, + formatter: Callable[[dict], str] | None = None, + ) -> None: + if path is not None and stream is not None: + raise ValueError("Cannot specify both 'path' and 'stream'") + if path is not None: + self._stream: IO[str] = open( # pylint: disable=consider-using-with + path, "a", encoding="utf-8" + ) + self._owns_stream = True + elif stream is not None: + self._stream = stream + self._owns_stream = False + else: + self._stream = sys.stdout + self._owns_stream = False + self._formatter = formatter or _format_line + self._shutdown = False + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return SpanExportResult.FAILURE + try: + lines = [ + self._formatter(resource_spans.to_dict()) + # pylint: disable-next=not-an-iterable + for resource_spans in encode_spans(spans).resource_spans + ] + self._stream.writelines(lines) + self._stream.flush() + # pylint: disable-next=broad-exception-caught + except Exception as error: + _logger.exception( + "Failed to write span batch to stream: %s: %s", + type(error).__name__, + error, + ) + return SpanExportResult.FAILURE + return SpanExportResult.SUCCESS + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._shutdown = True + if self._owns_stream: + self._stream.close() + + def force_flush(self, timeout_millis: int = 30000) -> bool: + """Nothing is buffered in this exporter, so this method does nothing.""" + return True diff --git a/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/version/__init__.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/version/__init__.py new file mode 100644 index 00000000000..716ad67f7d6 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/version/__init__.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.63b0.dev" diff --git a/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt new file mode 100644 index 00000000000..dee1f129bbe --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt @@ -0,0 +1,18 @@ +asgiref==3.7.2 +importlib-metadata==6.11.0 +iniconfig==2.0.0 +packaging==24.0 +pluggy==1.6.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 opentelemetry-proto-json +-e exporter/opentelemetry-exporter-otlp-json-common +-e exporter/opentelemetry-exporter-otlp-json-file diff --git a/exporter/opentelemetry-exporter-otlp-json-file/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py new file mode 100644 index 00000000000..339cd44f63c --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py @@ -0,0 +1,173 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import json +import unittest +from unittest.mock import patch + +from opentelemetry.exporter.otlp.json.file._internal import ( + _format_line, + _get_aggregation, + _get_temporality, +) +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 +from opentelemetry.sdk.metrics.view import ( + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, +) + +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.file._internal" + + +class TestFormatLine(unittest.TestCase): + def test_produces_valid_json(self): + result = _format_line({"a": 1, "b": "hello"}) + parsed = json.loads(result.strip()) + self.assertEqual(parsed, {"a": 1, "b": "hello"}) + + def test_newline_terminated(self): + result = _format_line({"x": 0}) + self.assertTrue(result.endswith("\n")) + + def test_compact_no_spaces(self): + result = _format_line({"a": 1, "b": 2}) + self.assertNotIn(" ", result) + + def test_nested_structure(self): + entry = {"outer": {"inner": [1, 2, 3]}, "flag": True} + result = _format_line(entry) + parsed = json.loads(result.strip()) + self.assertEqual(parsed, entry) + + +class TestGetTemporality(unittest.TestCase): + def test_temporality_default_is_cumulative(self): + result = _get_temporality(None) + for instrument_class in ( + Counter, + UpDownCounter, + Histogram, + ObservableCounter, + ObservableUpDownCounter, + ObservableGauge, + ): + with self.subTest(instrument=instrument_class.__name__): + self.assertEqual( + result[instrument_class], + AggregationTemporality.CUMULATIVE, + ) + + def test_temporality_delta_env(self): + delta_cases = { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.DELTA, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + with patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "DELTA"}, + ): + result = _get_temporality(None) + for instrument_class, expected in delta_cases.items(): + with self.subTest(instrument=instrument_class.__name__): + self.assertEqual(result[instrument_class], expected) + + def test_temporality_lowmemory_env(self): + lowmemory_cases = { + Counter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, + Histogram: AggregationTemporality.DELTA, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + with patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "LOWMEMORY"}, + ): + result = _get_temporality(None) + for instrument_class, expected in lowmemory_cases.items(): + with self.subTest(instrument=instrument_class.__name__): + self.assertEqual(result[instrument_class], expected) + + def test_temporality_invalid_env_logs_warning(self): + with patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "INVALID"}, + ): + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = _get_temporality(None) + self.assertEqual( + result[Counter], + AggregationTemporality.CUMULATIVE, + ) + + def test_temporality_override_takes_precedence(self): + with patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "CUMULATIVE"}, + ): + result = _get_temporality({Counter: AggregationTemporality.DELTA}) + self.assertEqual(result[Counter], AggregationTemporality.DELTA) + + +class TestGetAggregation(unittest.TestCase): + def test_aggregation_default_is_explicit_bucket(self): + result = _get_aggregation(None) + self.assertIsInstance( + result[Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_aggregation_exponential_env(self): + with patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "base2_exponential_bucket_histogram" + }, + ): + result = _get_aggregation(None) + self.assertIsInstance( + result[Histogram], + ExponentialBucketHistogramAggregation, + ) + + def test_aggregation_invalid_env_logs_warning(self): + with patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "unknown_aggregation" + }, + ): + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = _get_aggregation(None) + self.assertIsInstance( + result[Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_aggregation_override_takes_precedence(self): + custom_aggregation = ExponentialBucketHistogramAggregation() + with patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "explicit_bucket_histogram" + }, + ): + result = _get_aggregation({Histogram: custom_aggregation}) + self.assertIs(result[Histogram], custom_aggregation) diff --git a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py new file mode 100644 index 00000000000..57059c96169 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py @@ -0,0 +1,198 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import io +import os +import sys +import tempfile +import unittest +from unittest.mock import Mock + +from opentelemetry._logs import LogRecord, SeverityNumber +from opentelemetry.exporter.otlp.json.common._log_encoder import encode_logs +from opentelemetry.exporter.otlp.json.file._internal import _format_line +from opentelemetry.exporter.otlp.json.file._log_exporter import ( + FileLogExporter, +) +from opentelemetry.proto_json.logs.v1.logs import ResourceLogs +from opentelemetry.sdk._logs import ( + LoggerProvider, + ReadableLogRecord, +) +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + LogRecordExportResult, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.file._log_exporter" + + +def _make_log_record( + body: str = "test log message", + resource_attrs: dict | None = None, +) -> ReadableLogRecord: + return ReadableLogRecord( + LogRecord( + body=body, + severity_text="INFO", + severity_number=SeverityNumber.INFO, + ), + resource=Resource(resource_attrs or {"service.name": "test"}), + instrumentation_scope=InstrumentationScope("test-scope", "1.0"), + ) + + +class TestFileLogExporter(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._exporter = FileLogExporter(stream=self._stream) + + def test_export_empty_sequence(self): + result = self._exporter.export([]) + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(self._stream.getvalue(), "") + + def test_export_single_log(self): + result = self._exporter.export([_make_log_record("hello from test")]) + self.assertEqual(result, LogRecordExportResult.SUCCESS) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + rl = ResourceLogs.from_json(lines[0]) + self.assertEqual( + rl.scope_logs[0].log_records[0].body.string_value, # type: ignore # pylint: disable=unsubscriptable-object + "hello from test", + ) + + def test_export_multiple_logs_same_resource(self): + logs = [ + _make_log_record("first"), + _make_log_record("second"), + ] + self._exporter.export(logs) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + rl = ResourceLogs.from_json(lines[0]) + total_logs = sum(len(sl.log_records) for sl in rl.scope_logs) # pylint: disable=not-an-iterable + self.assertEqual(total_logs, 2) + + def test_export_logs_different_resources(self): + logs = [ + _make_log_record("from-a", resource_attrs={"host": "a"}), + _make_log_record("from-b", resource_attrs={"host": "b"}), + ] + self._exporter.export(logs) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 2) + bodies = { + ResourceLogs.from_json(line) # pylint: disable=unsubscriptable-object + .scope_logs[0] + .log_records[0] + .body.string_value # type: ignore + for line in lines + } + self.assertEqual(bodies, {"from-a", "from-b"}) + + # pylint: disable-next=no-self-use + def test_stream_flushed_after_export(self): + mock_stream = Mock() + exporter = FileLogExporter(stream=mock_stream) + exporter.export([_make_log_record()]) + mock_stream.flush.assert_called_once() + + def test_custom_formatter(self): + formatter = Mock(return_value="formatted\n") + exporter = FileLogExporter(stream=self._stream, formatter=formatter) + exporter.export([_make_log_record()]) + formatter.assert_called_once() + self.assertIn("formatted\n", self._stream.getvalue()) + + def test_export_after_shutdown(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = self._exporter.export([_make_log_record()]) + self.assertEqual(result, LogRecordExportResult.FAILURE) + self.assertEqual(self._stream.getvalue(), "") + + def test_shutdown_idempotent(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + self._exporter.shutdown() + + def test_export_stream_error(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileLogExporter(stream=mock_stream) + with self.assertLogs(_LOGGER_NAME, level="ERROR"): + result = exporter.export([_make_log_record()]) + self.assertEqual(result, LogRecordExportResult.FAILURE) + + def test_export_with_path(self): + # pylint: disable-next=consider-using-with + tmp_dir = tempfile.TemporaryDirectory() + path = os.path.join(tmp_dir.name, "output.jsonl") + exporter = FileLogExporter(path) + exporter.export([_make_log_record("hello from path")]) + exporter.shutdown() + with open(path, encoding="utf-8") as fh: + rl = ResourceLogs.from_json(fh.read().splitlines()[0]) + self.assertEqual( + rl.scope_logs[0].log_records[0].body.string_value, # type: ignore # pylint: disable=unsubscriptable-object + "hello from path", + ) + tmp_dir.cleanup() + + def test_path_and_stream_raises(self): + with self.assertRaises(ValueError): + FileLogExporter("output.jsonl", stream=self._stream) # type: ignore + + def test_default_stream_is_stdout(self): + exporter = FileLogExporter() + # pylint: disable-next=protected-access + self.assertIs(exporter._stream, sys.stdout) + + +class TestFileLogExporterRoundTrip(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._file_exporter = FileLogExporter(stream=self._stream) + self._in_memory = InMemoryLogRecordExporter() + provider = LoggerProvider() + provider.add_log_record_processor( + SimpleLogRecordProcessor(self._file_exporter) + ) + provider.add_log_record_processor( + SimpleLogRecordProcessor(self._in_memory) + ) + self._logger = provider.get_logger("test.integration") + + def _expected(self) -> str: + return "".join( + _format_line(rl.to_dict()) + for record in self._in_memory.get_finished_logs() + for rl in encode_logs([record]).resource_logs # pylint: disable=not-an-iterable + ) + + def test_single_log_matches_in_memory(self): + self._logger.emit( + LogRecord( + body="hello from integration", + severity_number=SeverityNumber.INFO, + ) + ) + self.assertEqual(self._stream.getvalue(), self._expected()) + + def test_multiple_logs_match_in_memory(self): + self._logger.emit( + LogRecord( + body="first message", severity_number=SeverityNumber.INFO + ) + ) + self._logger.emit( + LogRecord( + body="second message", severity_number=SeverityNumber.WARN + ) + ) + self.assertEqual(self._stream.getvalue(), self._expected()) diff --git a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py new file mode 100644 index 00000000000..243fc53e505 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py @@ -0,0 +1,231 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import io +import os +import sys +import tempfile +import unittest +from typing import IO +from unittest.mock import Mock + +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.exporter.otlp.json.file._internal import _format_line +from opentelemetry.exporter.otlp.json.file.metric_exporter import ( + FileMetricExporter, +) +from opentelemetry.metrics import Observation +from opentelemetry.proto_json.metrics.v1.metrics import ( + ResourceMetrics as OtlpResourceMetrics, +) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics._internal.export import MetricExportResult +from opentelemetry.sdk.metrics._internal.point import ( + MetricsData, + ResourceMetrics, + ScopeMetrics, +) +from opentelemetry.sdk.metrics.export import ( + PeriodicExportingMetricReader, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.test.metrictestutil import _generate_gauge, _generate_sum + +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.file.metric_exporter" + + +class _CapturingMetricExporter(FileMetricExporter): + def __init__(self, *, stream: IO[str]) -> None: + super().__init__(stream=stream) + self.last_metrics_data: MetricsData | None = None + + def export( + self, + metrics_data: MetricsData, + timeout_millis: float = 10_000, + **kwargs, + ) -> MetricExportResult: + self.last_metrics_data = metrics_data + return super().export( + metrics_data, timeout_millis=timeout_millis, **kwargs + ) + + +def _make_metrics_data() -> MetricsData: + return MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource({"service.name": "test-service"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("test-scope", "1.0"), + metrics=[_generate_sum("requests", 42)], + schema_url="", + ) + ], + schema_url="", + ) + ] + ) + + +class TestFileMetricExporter(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._exporter = FileMetricExporter(stream=self._stream) + + def test_export_metrics(self): + result = self._exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.SUCCESS) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + rm = OtlpResourceMetrics.from_json(lines[0]) + # pylint: disable-next=unsubscriptable-object + self.assertEqual(rm.scope_metrics[0].metrics[0].name, "requests") + + # pylint: disable-next=no-self-use + def test_stream_flushed_after_export(self): + mock_stream = Mock() + exporter = FileMetricExporter(stream=mock_stream) + exporter.export(_make_metrics_data()) + mock_stream.flush.assert_called_once() + + def test_custom_formatter(self): + formatter = Mock(return_value="formatted\n") + exporter = FileMetricExporter(stream=self._stream, formatter=formatter) + exporter.export(_make_metrics_data()) + formatter.assert_called_once() + self.assertIn("formatted\n", self._stream.getvalue()) + + def test_export_multiple_resource_metrics(self): + data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource({"host": "a"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("s", "1"), + metrics=[_generate_sum("counter_a", 1)], + schema_url="", + ) + ], + schema_url="", + ), + ResourceMetrics( + resource=Resource({"host": "b"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("s", "1"), + metrics=[_generate_gauge("gauge_b", 2)], + schema_url="", + ) + ], + schema_url="", + ), + ] + ) + self._exporter.export(data) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 2) + names = { + OtlpResourceMetrics.from_json(line) # pylint: disable=unsubscriptable-object + .scope_metrics[0] + .metrics[0] + .name + for line in lines + } + self.assertEqual(names, {"counter_a", "gauge_b"}) + + def test_export_after_shutdown(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = self._exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.FAILURE) + self.assertEqual(self._stream.getvalue(), "") + + def test_shutdown_idempotent(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + self._exporter.shutdown() + + def test_force_flush_returns_true(self): + self.assertTrue(self._exporter.force_flush()) + + def test_export_stream_error(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileMetricExporter(stream=mock_stream) + with self.assertLogs(_LOGGER_NAME, level="ERROR"): + result = exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.FAILURE) + + def test_export_with_path(self): + # pylint: disable-next=consider-using-with + tmp_dir = tempfile.TemporaryDirectory() + path = os.path.join(tmp_dir.name, "output.jsonl") + exporter = FileMetricExporter(path) + exporter.export(_make_metrics_data()) + exporter.shutdown() + with open(path, encoding="utf-8") as fh: + rm = OtlpResourceMetrics.from_json(fh.read().splitlines()[0]) + # pylint: disable-next=unsubscriptable-object + self.assertEqual(rm.scope_metrics[0].metrics[0].name, "requests") + tmp_dir.cleanup() + + def test_path_and_stream_raises(self): + with self.assertRaises(ValueError): + FileMetricExporter("output.jsonl", stream=self._stream) # type: ignore + + def test_default_stream_is_stdout(self): + exporter = FileMetricExporter() + # pylint: disable-next=protected-access + self.assertIs(exporter._stream, sys.stdout) + + +class TestFileMetricExporterRoundTrip(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._exporter = _CapturingMetricExporter(stream=self._stream) + self._provider = MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader( + self._exporter, export_interval_millis=100_000 + ), + ] + ) + self._meter = self._provider.get_meter(__name__) + + def tearDown(self): + self._provider.shutdown() + + def _expected(self) -> str: + return "".join( + _format_line(rm.to_dict()) + for rm in encode_metrics( # pylint: disable=not-an-iterable + self._exporter.last_metrics_data # type: ignore + ).resource_metrics + ) + + def test_synchronous_instruments_match_in_memory(self): + self._meter.create_counter("req.count").add(10) + self._meter.create_up_down_counter("queue.depth").add(-3) + self._meter.create_histogram("req.duration").record(1.5) + self._meter.create_gauge("cpu.temp").set(72.0) + self._provider.force_flush() + self.assertEqual(self._stream.getvalue(), self._expected()) + + def test_observable_instruments_match_in_memory(self): + self._meter.create_observable_counter( + "obs.req.count", callbacks=[lambda _: [Observation(20)]] + ) + self._meter.create_observable_up_down_counter( + "obs.queue.depth", callbacks=[lambda _: [Observation(-7)]] + ) + self._meter.create_observable_gauge( + "obs.cpu.temp", callbacks=[lambda _: [Observation(55.5)]] + ) + self._provider.force_flush() + self.assertEqual(self._stream.getvalue(), self._expected()) diff --git a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py new file mode 100644 index 00000000000..7d22581feec --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py @@ -0,0 +1,191 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import io +import os +import sys +import tempfile +import unittest +from unittest.mock import Mock + +from opentelemetry.exporter.otlp.json.common.trace_encoder import encode_spans +from opentelemetry.exporter.otlp.json.file._internal import _format_line +from opentelemetry.exporter.otlp.json.file.trace_exporter import ( + FileSpanExporter, +) +from opentelemetry.proto_json.trace.v1.trace import ResourceSpans +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, + SpanExportResult, +) +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.trace import Link, SpanContext, StatusCode, TraceFlags + +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.file.trace_exporter" + + +class TestFileSpanExporter(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._exporter = FileSpanExporter(stream=self._stream) + + self._in_memory = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(self._in_memory)) + self._tracer = provider.get_tracer(__name__) + + def _finished_spans(self): + return list(self._in_memory.get_finished_spans()) + + def _make_span(self, name: str = "test-span"): + with self._tracer.start_as_current_span(name): + pass + return self._finished_spans() + + def test_export_empty_sequence(self): + result = self._exporter.export([]) + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(self._stream.getvalue(), "") + + def test_export_single_span(self): + result = self._exporter.export(self._make_span("my-span")) + self.assertEqual(result, SpanExportResult.SUCCESS) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + rs = ResourceSpans.from_json(lines[0]) + # pylint: disable-next=unsubscriptable-object + self.assertEqual(rs.scope_spans[0].spans[0].name, "my-span") + + def test_export_multiple_spans_same_resource(self): + with self._tracer.start_as_current_span("first"): + pass + with self._tracer.start_as_current_span("second"): + pass + self._exporter.export(self._finished_spans()) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + rs = ResourceSpans.from_json(lines[0]) + total_spans = sum(len(ss.spans) for ss in rs.scope_spans) # pylint: disable=not-an-iterable + self.assertEqual(total_spans, 2) + + def test_stream_flushed_after_export(self): + mock_stream = Mock() + exporter = FileSpanExporter(stream=mock_stream) + exporter.export(self._make_span()) + mock_stream.flush.assert_called_once() + + def test_custom_formatter(self): + formatter = Mock(return_value="formatted\n") + exporter = FileSpanExporter(stream=self._stream, formatter=formatter) + exporter.export(self._make_span()) + formatter.assert_called_once() + self.assertIn("formatted\n", self._stream.getvalue()) + + def test_export_after_shutdown(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = self._exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.FAILURE) + self.assertEqual(self._stream.getvalue(), "") + + def test_shutdown_idempotent(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + self._exporter.shutdown() + + def test_force_flush_returns_true(self): + self.assertTrue(self._exporter.force_flush()) + self._exporter.export(self._make_span()) + self.assertTrue(self._exporter.force_flush()) + + def test_export_stream_error(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileSpanExporter(stream=mock_stream) + with self.assertLogs(_LOGGER_NAME, level="ERROR"): + result = exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.FAILURE) + + def test_export_with_path(self): + # pylint: disable-next=consider-using-with + tmp_dir = tempfile.TemporaryDirectory() + path = os.path.join(tmp_dir.name, "output.jsonl") + exporter = FileSpanExporter(path) + exporter.export(self._make_span("path-span")) + exporter.shutdown() + with open(path, encoding="utf-8") as fh: + rs = ResourceSpans.from_json(fh.read().splitlines()[0]) + # pylint: disable-next=unsubscriptable-object + self.assertEqual(rs.scope_spans[0].spans[0].name, "path-span") + tmp_dir.cleanup() + + def test_path_and_stream_raises(self): + with self.assertRaises(ValueError): + FileSpanExporter("output.jsonl", stream=self._stream) # type: ignore + + def test_default_stream_is_stdout(self): + exporter = FileSpanExporter() + # pylint: disable-next=protected-access + self.assertIs(exporter._stream, sys.stdout) + + +class TestFileSpanExporterRoundTrip(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._file_exporter = FileSpanExporter(stream=self._stream) + self._in_memory = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(self._file_exporter)) + provider.add_span_processor(SimpleSpanProcessor(self._in_memory)) + self._tracer = provider.get_tracer(__name__) + + def _expected(self) -> str: + return "".join( + _format_line(rs.to_dict()) + for span in self._in_memory.get_finished_spans() + for rs in encode_spans([span]).resource_spans # pylint: disable=not-an-iterable + ) + + def test_single_span_matches_in_memory(self): + link_ctx = SpanContext( + trace_id=0x000000000000000000000000DEADBEEF, + span_id=0x00000000DEADBEF0, + is_remote=True, + trace_flags=TraceFlags(0x01), + ) + with self._tracer.start_as_current_span( + "rich-span", + links=[Link(link_ctx, {"link.order": 1})], + ) as span: + span.set_attributes( + { + "http.method": "GET", + "http.status_code": 200, + "http.retried": False, + } + ) + span.add_event("cache-miss", {"cache.key": "user:42"}) + span.set_status(StatusCode.OK) + self.assertEqual(self._stream.getvalue(), self._expected()) + + def test_multiple_spans_match_in_memory(self): + with self._tracer.start_as_current_span( + "parent-op", attributes={"phase": "request"} + ) as parent: + parent.add_event("processing-started") + with self._tracer.start_as_current_span("child-op") as child: + child.set_attribute("attempt", 1) + child.set_status(StatusCode.ERROR, "timeout") + self.assertEqual(self._stream.getvalue(), self._expected()) + + def test_recorded_exception_matches_in_memory(self): + with self._tracer.start_as_current_span("failing-op") as span: + try: + raise ValueError("something went wrong") + except ValueError as exc: + span.record_exception(exc) + span.set_status(StatusCode.ERROR, str(exc)) + self.assertEqual(self._stream.getvalue(), self._expected()) diff --git a/pyproject.toml b/pyproject.toml index 28295af8658..2b9e4c5e58f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry-proto-json", "opentelemetry-test-utils", + "opentelemetry-exporter-otlp-json-file", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-exporter-otlp-proto-common", @@ -32,6 +33,7 @@ opentelemetry-proto = { workspace = true } opentelemetry-proto-json = { workspace = true } opentelemetry-semantic-conventions = { workspace = true } opentelemetry-test-utils = { workspace = true } +opentelemetry-exporter-otlp-json-file = { workspace = true } opentelemetry-exporter-otlp-proto-grpc = { workspace = true } opentelemetry-exporter-otlp-proto-http = { workspace = true } opentelemetry-exporter-otlp-proto-common = { workspace = true } @@ -119,6 +121,7 @@ include = [ "opentelemetry-api", "opentelemetry-sdk", "opentelemetry-proto-json", + "exporter/opentelemetry-exporter-otlp-json-file", "exporter/opentelemetry-exporter-otlp-proto-grpc", "exporter/opentelemetry-exporter-otlp-proto-http", "exporter/opentelemetry-exporter-otlp-json-common", diff --git a/tox.ini b/tox.ini index 5ebf34c3f37..c746d7b349a 100644 --- a/tox.ini +++ b/tox.ini @@ -62,6 +62,10 @@ envlist = ; intentionally excluded from pypy3 lint-opentelemetry-exporter-otlp-combined + py3{10,11,12,13,14,14t}-test-opentelemetry-exporter-otlp-json-file + pypy3-test-opentelemetry-exporter-otlp-json-file + lint-opentelemetry-exporter-otlp-json-file + py3{10,11,12,13,14}-test-opentelemetry-exporter-otlp-proto-grpc-{oldest,latest} ; intentionally excluded from pypy3 lint-opentelemetry-exporter-otlp-proto-grpc-latest @@ -145,6 +149,8 @@ deps = exporter-otlp-combined: -r {toxinidir}/exporter/opentelemetry-exporter-otlp/test-requirements.txt + exporter-otlp-json-file: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt + opentelemetry-exporter-otlp-proto-grpc-oldest: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc/test-requirements.oldest.txt opentelemetry-exporter-otlp-proto-grpc-latest: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc/test-requirements.latest.txt benchmark-exporter-otlp-proto-grpc: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc/benchmark-requirements.txt @@ -243,6 +249,9 @@ commands = 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" + test-opentelemetry-exporter-otlp-json-file: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file/tests {posargs} + lint-opentelemetry-exporter-otlp-json-file: sh -c "cd exporter && pylint --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file" + test-opentelemetry-exporter-otlp-proto-grpc: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc/tests {posargs} lint-opentelemetry-exporter-otlp-proto-grpc: sh -c "cd exporter && pylint --prefer-stubs yes --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc" benchmark-opentelemetry-exporter-otlp-proto-grpc: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc/benchmarks --benchmark-json=exporter-otlp-proto-grpc-benchmark.json {posargs} @@ -405,6 +414,7 @@ deps = -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-common -e {toxinidir}/exporter/opentelemetry-exporter-otlp-json-common -e {toxinidir}/exporter/opentelemetry-exporter-otlp + -e {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http -e {toxinidir}/opentelemetry-proto diff --git a/uv.lock b/uv.lock index c7eb242e8dc..c11cb7795a4 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "opentelemetry-codegen-json", "opentelemetry-exporter-otlp", "opentelemetry-exporter-otlp-json-common", + "opentelemetry-exporter-otlp-json-file", "opentelemetry-exporter-otlp-proto-common", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-exporter-otlp-proto-http", @@ -868,6 +869,20 @@ requires-dist = [ { name = "opentelemetry-sdk", editable = "opentelemetry-sdk" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-json-file" +source = { editable = "exporter/opentelemetry-exporter-otlp-json-file" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-json-common" }, + { name = "opentelemetry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-exporter-otlp-json-common", editable = "exporter/opentelemetry-exporter-otlp-json-common" }, + { name = "opentelemetry-sdk", editable = "opentelemetry-sdk" }, +] + [[package]] name = "opentelemetry-exporter-otlp-proto-common" source = { editable = "exporter/opentelemetry-exporter-otlp-proto-common" } @@ -1020,6 +1035,7 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-codegen-json" }, { name = "opentelemetry-exporter-otlp-json-common" }, + { name = "opentelemetry-exporter-otlp-json-file" }, { name = "opentelemetry-exporter-otlp-proto-common" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -1048,6 +1064,7 @@ requires-dist = [ { name = "opentelemetry-api", editable = "opentelemetry-api" }, { name = "opentelemetry-codegen-json", editable = "codegen/opentelemetry-codegen-json" }, { name = "opentelemetry-exporter-otlp-json-common", editable = "exporter/opentelemetry-exporter-otlp-json-common" }, + { name = "opentelemetry-exporter-otlp-json-file", editable = "exporter/opentelemetry-exporter-otlp-json-file" }, { name = "opentelemetry-exporter-otlp-proto-common", editable = "exporter/opentelemetry-exporter-otlp-proto-common" }, { name = "opentelemetry-exporter-otlp-proto-grpc", editable = "exporter/opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http", editable = "exporter/opentelemetry-exporter-otlp-proto-http" },