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" },