From bbaa9e1e63866c5708d60e16cbc3e8220e783413 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 13 May 2026 21:14:47 -0400 Subject: [PATCH 1/9] commit --- .../LICENSE | 201 ++++++++++++++++++ .../README.rst | 23 ++ .../pyproject.toml | 47 ++++ .../exporter/otlp/json/file/__init__.py | 2 + .../exporter/otlp/json/file/_internal.py | 5 + .../exporter/otlp/json/file/_log_exporter.py | 0 .../otlp/json/file/metric_exporter.py | 144 +++++++++++++ .../exporter/otlp/json/file/py.typed | 0 .../exporter/otlp/json/file/trace_exporter.py | 48 +++++ .../otlp/json/file/version/__init__.py | 4 + uv.lock | 15 ++ 11 files changed, 489 insertions(+) create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/LICENSE create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/README.rst create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_internal.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_log_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/py.typed create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/trace_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/version/__init__.py 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..2040697a059 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/README.rst @@ -0,0 +1,23 @@ +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 allows to export data files using OTLP JSON. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-otlp-json-file + + +References +---------- + +* `OpenTelemetry `_ +* `OpenTelemetry Protocol Specification `_ 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..50df8c63b12 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml @@ -0,0 +1,47 @@ +[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.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..a5f4b1c4003 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/_internal.py @@ -0,0 +1,5 @@ +import json + + +def _format_line(entry: dict) -> str: + return json.dumps(entry, separators=(",", ".")) + "\n" 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..e69de29bb2d 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..62557643d70 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py @@ -0,0 +1,144 @@ +import logging +from collections.abc import Callable +from os import environ +from typing import IO + +from opentelemetry.exporter.otlp.json.file._internal import _format_line +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._internal.aggregation import ( + Aggregation, + AggregationTemporality, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, +) +from opentelemetry.sdk.metrics._internal.export import MetricExporter, MetricExportResult +from opentelemetry.sdk.metrics._internal.point import MetricsData + +_logger = logging.getLogger(__name__) + + +class FileMetricExporter(MetricExporter): + def __init__( + self, + stream: IO[str], + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + *, + _formatter: Callable[[dict], str] | None = None, + ) -> None: + MetricExporter.__init__( + self, + preferred_temporality=_get_temporality(preferred_temporality), + preferred_aggregation=_get_aggregation(preferred_aggregation), + ) + self._stream = stream + self._formatter = _formatter or _format_line + self._shutdown = False + + def export(self, metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs) -> MetricExportResult: + pass + + def force_flush(self, timeout_millis: float = 10_000) -> bool: + pass + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + pass + + + +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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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/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..61e1d720b49 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/trace_exporter.py @@ -0,0 +1,48 @@ +import logging +from collections.abc import Callable, Sequence +from typing import IO + +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): + def __init__( + self, + stream: IO[str], + *, + _formatter: Callable[[dict], str] | None = None, + ) -> None: + self._stream = stream + 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(span.to_dict()) + for span in encode_spans(spans).resource_spans + ] + self._stream.writelines(lines) + self._stream.flush() + return SpanExportResult.SUCCESS + except Exception as error: + _logger.error("Failed to export span batch: %s", error) + return SpanExportResult.FAILURE + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._shutdown = True + + 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/uv.lock b/uv.lock index 0a37927e0ac..e969913371c 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", @@ -884,6 +885,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" } From 4715df2c76697f508fcceb141ff067d744e751de Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 13 May 2026 23:01:46 -0400 Subject: [PATCH 2/9] finish added file exporters --- .../README.rst | 13 ++++- .../exporter/otlp/json/file/_log_exporter.py | 56 +++++++++++++++++++ .../otlp/json/file/metric_exporter.py | 51 +++++++++++++---- .../exporter/otlp/json/file/trace_exporter.py | 14 +++-- 4 files changed, 116 insertions(+), 18 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-file/README.rst b/exporter/opentelemetry-exporter-otlp-json-file/README.rst index 2040697a059..11c8dbef554 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/README.rst +++ b/exporter/opentelemetry-exporter-otlp-json-file/README.rst @@ -6,7 +6,11 @@ OpenTelemetry JSON File Exporter .. |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 allows to export data files using OTLP JSON. +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 ------------ @@ -19,5 +23,8 @@ Installation References ---------- -* `OpenTelemetry `_ -* `OpenTelemetry Protocol Specification `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Protocol File Exporter `_ +* `OTLP Specification `_ +* `OTLP JSON Encoding Specification `_ +* `JSON Lines `_ 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 index e69de29bb2d..07b69fc62f6 100644 --- 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 @@ -0,0 +1,56 @@ +import logging +from collections.abc import Callable, Sequence +from typing import IO + +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): + def __init__( + self, + stream: IO[str], + *, + _formatter: Callable[[dict], str] | None = None, + ) -> None: + self._stream = stream + 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(rls.to_dict()) + for rls in encode_logs(batch).resource_logs + ] + self._stream.writelines(lines) + self._stream.flush() + 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 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 index 62557643d70..c9ef7a4a06b 100644 --- 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 @@ -3,6 +3,9 @@ from os import environ from typing import IO +from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( + encode_metrics, +) from opentelemetry.exporter.otlp.json.file._internal import _format_line from opentelemetry.sdk.environment_variables import ( OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION, @@ -22,7 +25,10 @@ ExplicitBucketHistogramAggregation, ExponentialBucketHistogramAggregation, ) -from opentelemetry.sdk.metrics._internal.export import MetricExporter, MetricExportResult +from opentelemetry.sdk.metrics._internal.export import ( + MetricExporter, + MetricExportResult, +) from opentelemetry.sdk.metrics._internal.point import MetricsData _logger = logging.getLogger(__name__) @@ -47,15 +53,40 @@ def __init__( self._formatter = _formatter or _format_line self._shutdown = False - def export(self, metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs) -> MetricExportResult: - pass - - def force_flush(self, timeout_millis: float = 10_000) -> bool: - pass + 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(rms.to_dict()) + for rms in encode_metrics(metrics_data).resource_metrics + ] + self._stream.writelines(lines) + self._stream.flush() + 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: - pass + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._shutdown = True + def force_flush(self, timeout_millis: float = 10_000) -> bool: + """Nothing is buffered in this exporter, so this method does nothing.""" + return True def _get_temporality( @@ -91,7 +122,7 @@ def _get_temporality( } else: - if temporality_preference != ("CUMULATIVE"): + if temporality_preference != "CUMULATIVE": _logger.warning( "Unrecognized OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE" " value found: " @@ -120,13 +151,13 @@ def _get_aggregation( "explicit_bucket_histogram", ) - if default_histogram_aggregation == ("base2_exponential_bucket_histogram"): + if default_histogram_aggregation == "base2_exponential_bucket_histogram": instrument_class_aggregation = { Histogram: ExponentialBucketHistogramAggregation(), } else: - if default_histogram_aggregation != ("explicit_bucket_histogram"): + if default_histogram_aggregation != "explicit_bucket_histogram": _logger.warning( ( "Invalid value for %s: %s, using explicit bucket " 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 index 61e1d720b49..6074ab373bc 100644 --- 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 @@ -27,15 +27,19 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: return SpanExportResult.FAILURE try: lines = [ - self._formatter(span.to_dict()) - for span in encode_spans(spans).resource_spans + self._formatter(rss.to_dict()) + for rss in encode_spans(spans).resource_spans ] self._stream.writelines(lines) self._stream.flush() - return SpanExportResult.SUCCESS except Exception as error: - _logger.error("Failed to export span batch: %s", error) - return SpanExportResult.FAILURE + _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: From 8c51abde218defa016393eac90750a5a3867156a Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 13 May 2026 23:04:15 -0400 Subject: [PATCH 3/9] update license headers --- .../src/opentelemetry/exporter/otlp/json/file/_internal.py | 3 +++ .../src/opentelemetry/exporter/otlp/json/file/_log_exporter.py | 3 +++ .../opentelemetry/exporter/otlp/json/file/metric_exporter.py | 3 +++ .../opentelemetry/exporter/otlp/json/file/trace_exporter.py | 3 +++ 4 files changed, 12 insertions(+) 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 index a5f4b1c4003..b488873a52d 100644 --- 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 @@ -1,3 +1,6 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + import json 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 index 07b69fc62f6..4f6cc98c0f6 100644 --- 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 @@ -1,3 +1,6 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + import logging from collections.abc import Callable, Sequence from typing import IO 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 index c9ef7a4a06b..ea97257035a 100644 --- 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 @@ -1,3 +1,6 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + import logging from collections.abc import Callable from os import environ 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 index 6074ab373bc..533089f8587 100644 --- 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 @@ -1,3 +1,6 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + import logging from collections.abc import Callable, Sequence from typing import IO From 475c68c25f4468788293d123e422ae21678e1723 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 13 May 2026 23:08:54 -0400 Subject: [PATCH 4/9] add changelog fragment --- .changelog/5207.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5207.added 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 From 682e3ea8a511bf6966b6ae293f0b429cec6eb4fa Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 15 May 2026 00:05:02 -0400 Subject: [PATCH 5/9] add test tox environments --- .../exporter/otlp/json/file/_log_exporter.py | 5 +++-- .../exporter/otlp/json/file/metric_exporter.py | 18 ++++++++++-------- .../exporter/otlp/json/file/trace_exporter.py | 6 ++++-- .../test-requirements.txt | 18 ++++++++++++++++++ .../tests/__init__.py | 0 .../tests/test_log_exporter.py | 2 ++ .../tests/test_metric_exporter.py | 0 .../tests/test_trace_exporter.py | 0 pyproject.toml | 3 +++ tox.ini | 10 ++++++++++ uv.lock | 2 ++ 11 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/tests/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py 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 index 4f6cc98c0f6..2ab77afe768 100644 --- 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 @@ -38,11 +38,12 @@ def export( return LogRecordExportResult.FAILURE try: lines = [ - self._formatter(rls.to_dict()) - for rls in encode_logs(batch).resource_logs + 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", 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 index ea97257035a..0f6af7a7ebe 100644 --- 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 @@ -67,11 +67,14 @@ def export( return MetricExportResult.FAILURE try: lines = [ - self._formatter(rms.to_dict()) - for rms in encode_metrics(metrics_data).resource_metrics + 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", @@ -105,7 +108,7 @@ def _get_temporality( ) if temporality_preference == "DELTA": - instrument_class_temporality = { + instrument_class_temporality: dict[type, AggregationTemporality] = { Counter: AggregationTemporality.DELTA, UpDownCounter: AggregationTemporality.CUMULATIVE, Histogram: AggregationTemporality.DELTA, @@ -115,7 +118,7 @@ def _get_temporality( } elif temporality_preference == "LOWMEMORY": - instrument_class_temporality = { + instrument_class_temporality: dict[type, AggregationTemporality] = { Counter: AggregationTemporality.DELTA, UpDownCounter: AggregationTemporality.CUMULATIVE, Histogram: AggregationTemporality.DELTA, @@ -133,7 +136,7 @@ def _get_temporality( "using CUMULATIVE", temporality_preference, ) - instrument_class_temporality = { + instrument_class_temporality: dict[type, AggregationTemporality] = { Counter: AggregationTemporality.CUMULATIVE, UpDownCounter: AggregationTemporality.CUMULATIVE, Histogram: AggregationTemporality.CUMULATIVE, @@ -155,10 +158,9 @@ def _get_aggregation( ) if default_histogram_aggregation == "base2_exponential_bucket_histogram": - instrument_class_aggregation = { + instrument_class_aggregation: dict[type, Aggregation] = { Histogram: ExponentialBucketHistogramAggregation(), } - else: if default_histogram_aggregation != "explicit_bucket_histogram": _logger.warning( @@ -170,7 +172,7 @@ def _get_aggregation( default_histogram_aggregation, ) - instrument_class_aggregation = { + instrument_class_aggregation: dict[type, Aggregation] = { Histogram: ExplicitBucketHistogramAggregation(), } 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 index 533089f8587..b8de6c83c3e 100644 --- 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 @@ -30,11 +30,13 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: return SpanExportResult.FAILURE try: lines = [ - self._formatter(rss.to_dict()) - for rss in encode_spans(spans).resource_spans + 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", 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_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py new file mode 100644 index 00000000000..f4f53619168 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py @@ -0,0 +1,2 @@ +def test_dummy(): + assert True 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..e69de29bb2d 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..e69de29bb2d diff --git a/pyproject.toml b/pyproject.toml index 66e4a179203..5d363f7c047 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 e969913371c..c48ba6c0410 100644 --- a/uv.lock +++ b/uv.lock @@ -1051,6 +1051,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" }, @@ -1079,6 +1080,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" }, From 12fe634b54e53e76c4259cb0b712f52edcfb33d4 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 15 May 2026 21:45:49 -0400 Subject: [PATCH 6/9] commit --- .../exporter/otlp/json/file/_internal.py | 2 +- .../exporter/otlp/json/file/_log_exporter.py | 9 +- .../otlp/json/file/metric_exporter.py | 21 +- .../exporter/otlp/json/file/trace_exporter.py | 5 +- .../tests/test_internal.py | 30 ++ .../tests/test_log_exporter.py | 184 ++++++++- .../tests/test_metric_exporter.py | 376 ++++++++++++++++++ .../tests/test_trace_exporter.py | 159 ++++++++ 8 files changed, 766 insertions(+), 20 deletions(-) create mode 100644 exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py 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 index b488873a52d..ffca32f97d2 100644 --- 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 @@ -5,4 +5,4 @@ def _format_line(entry: dict) -> str: - return json.dumps(entry, separators=(",", ".")) + "\n" + return json.dumps(entry, separators=(",", ":")) + "\n" 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 index 2ab77afe768..7e82ba74e96 100644 --- 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 @@ -23,11 +23,10 @@ class FileLogExporter(LogRecordExporter): def __init__( self, stream: IO[str], - *, - _formatter: Callable[[dict], str] | None = None, + formatter: Callable[[dict], str] | None = None, ) -> None: self._stream = stream - self._formatter = _formatter or _format_line + self._formatter = formatter or _format_line self._shutdown = False def export( @@ -58,3 +57,7 @@ def shutdown(self) -> None: _logger.warning("Exporter already shutdown, ignoring call") return self._shutdown = True + + 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/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py index 0f6af7a7ebe..b5fe10b5722 100644 --- 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 @@ -6,7 +6,7 @@ from os import environ from typing import IO -from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( encode_metrics, ) from opentelemetry.exporter.otlp.json.file._internal import _format_line @@ -22,17 +22,17 @@ ObservableUpDownCounter, UpDownCounter, ) -from opentelemetry.sdk.metrics._internal.aggregation import ( - Aggregation, +from opentelemetry.sdk.metrics.export import ( AggregationTemporality, - ExplicitBucketHistogramAggregation, - ExponentialBucketHistogramAggregation, -) -from opentelemetry.sdk.metrics._internal.export import ( MetricExporter, MetricExportResult, + MetricsData, +) +from opentelemetry.sdk.metrics.view import ( + Aggregation, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, ) -from opentelemetry.sdk.metrics._internal.point import MetricsData _logger = logging.getLogger(__name__) @@ -41,11 +41,10 @@ class FileMetricExporter(MetricExporter): 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, - *, - _formatter: Callable[[dict], str] | None = None, ) -> None: MetricExporter.__init__( self, @@ -53,7 +52,7 @@ def __init__( preferred_aggregation=_get_aggregation(preferred_aggregation), ) self._stream = stream - self._formatter = _formatter or _format_line + self._formatter = formatter or _format_line self._shutdown = False def export( 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 index b8de6c83c3e..11806341345 100644 --- 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 @@ -17,11 +17,10 @@ class FileSpanExporter(SpanExporter): def __init__( self, stream: IO[str], - *, - _formatter: Callable[[dict], str] | None = None, + formatter: Callable[[dict], str] | None = None, ) -> None: self._stream = stream - self._formatter = _formatter or _format_line + self._formatter = formatter or _format_line self._shutdown = False def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: 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..a4edd4ebf35 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py @@ -0,0 +1,30 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import unittest + +from opentelemetry.exporter.otlp.json.file._internal import _format_line + + +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) 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 index f4f53619168..ae66338fc9e 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py @@ -1,2 +1,182 @@ -def test_dummy(): - assert True +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import io +import json +import logging +import unittest +from unittest.mock import Mock, patch + +import opentelemetry.exporter.otlp.json.file._log_exporter as _log_exporter_mod +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.sdk._logs import ( + LoggerProvider, + LoggingHandler, + ReadableLogRecord, +) +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + LogRecordExportResult, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + + +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(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_returns_success(self): + result = self._exporter.export([_make_log_record()]) + self.assertEqual(result, LogRecordExportResult.SUCCESS) + + def test_export_single_log_writes_one_json_line(self): + self._exporter.export([_make_log_record()]) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + json.loads(lines[0]) # must be valid JSON + + def test_export_log_body_in_output(self): + self._exporter.export([_make_log_record("hello from test")]) + self.assertIn("hello from test", self._stream.getvalue()) + + def test_export_multiple_logs_same_resource_writes_one_line(self): + logs = [ + _make_log_record("first"), + _make_log_record("second"), + ] + self._exporter.export(logs) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + data = json.loads(lines[0]) + total_logs = sum(len(sl["logRecords"]) for sl in data["scopeLogs"]) + self.assertEqual(total_logs, 2) + + def test_export_logs_different_resources_writes_multiple_lines(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) + + def test_stream_flushed_after_export(self): + mock_stream = Mock() + exporter = FileLogExporter(mock_stream) + exporter.export([_make_log_record()]) + mock_stream.flush.assert_called_once() + + def test_custom_formatter_called(self): + formatter = Mock(return_value="formatted\n") + exporter = FileLogExporter(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_returns_failure(self): + self._exporter.shutdown() + result = self._exporter.export([_make_log_record()]) + self.assertEqual(result, LogRecordExportResult.FAILURE) + + def test_export_after_shutdown_logs_warning(self): + self._exporter.shutdown() + with patch.object(_log_exporter_mod._logger, "warning") as mock_warn: + self._exporter.export([]) + mock_warn.assert_called_once() + + def test_export_after_shutdown_writes_nothing(self): + self._exporter.shutdown() + self._exporter.export([_make_log_record()]) + self.assertEqual(self._stream.getvalue(), "") + + def test_shutdown_idempotent_logs_warning(self): + self._exporter.shutdown() + with patch.object(_log_exporter_mod._logger, "warning") as mock_warn: + self._exporter.shutdown() + mock_warn.assert_called_once() + + def test_force_flush_returns_true(self): + self.assertTrue(self._exporter.force_flush()) + + def test_export_stream_error_returns_failure(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileLogExporter(mock_stream) + result = exporter.export([_make_log_record()]) + self.assertEqual(result, LogRecordExportResult.FAILURE) + + def test_export_stream_error_logs_exception(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileLogExporter(mock_stream) + with patch.object(_log_exporter_mod._logger, "exception") as mock_exc: + exporter.export([_make_log_record()]) + mock_exc.assert_called_once() + + +class TestFileLogExporterIntegration(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._file_exporter = FileLogExporter(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) + ) + handler = LoggingHandler(logger_provider=provider) + self._logger = logging.getLogger("test.file.log.exporter.integration") + self._logger.addHandler(handler) + self._logger.setLevel(logging.DEBUG) + + def tearDown(self): + for h in self._logger.handlers[:]: + self._logger.removeHandler(h) + + 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 + ) + + def test_single_log_matches_in_memory(self): + self._logger.info("hello from integration") + self.assertEqual(self._stream.getvalue(), self._expected()) + + def test_multiple_logs_match_in_memory(self): + self._logger.info("first message") + self._logger.warning("second message") + 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 index e69de29bb2d..3723265cbcb 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py @@ -0,0 +1,376 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import io +import json +import unittest +from unittest.mock import Mock, patch + +from opentelemetry.exporter.otlp.json.file.metric_exporter import ( + FileMetricExporter, +) +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, + MeterProvider, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.sdk.metrics._internal.aggregation import ( + AggregationTemporality, + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, +) +from opentelemetry.sdk.metrics._internal.export import MetricExportResult +from opentelemetry.sdk.metrics._internal.point import ( + MetricsData, + ResourceMetrics, + ScopeMetrics, +) +from opentelemetry.sdk.metrics.export import ( + InMemoryMetricReader, + 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" + + +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(self._stream) + + def test_export_metrics_returns_success(self): + result = self._exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.SUCCESS) + + def test_export_metrics_writes_valid_json(self): + self._exporter.export(_make_metrics_data()) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + json.loads(lines[0]) + + def test_export_metric_name_in_output(self): + self._exporter.export(_make_metrics_data()) + self.assertIn("requests", self._stream.getvalue()) + + def test_stream_flushed_after_export(self): + mock_stream = Mock() + exporter = FileMetricExporter(mock_stream) + exporter.export(_make_metrics_data()) + mock_stream.flush.assert_called_once() + + def test_custom_formatter_called(self): + formatter = Mock(return_value="formatted\n") + exporter = FileMetricExporter(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_writes_one_line_each(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) + + def test_export_after_shutdown_returns_failure(self): + self._exporter.shutdown() + result = self._exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.FAILURE) + + def test_export_after_shutdown_logs_warning(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + self._exporter.export(_make_metrics_data()) + + def test_export_after_shutdown_writes_nothing(self): + self._exporter.shutdown() + self._exporter.export(_make_metrics_data()) + self.assertEqual(self._stream.getvalue(), "") + + def test_shutdown_idempotent_logs_warning(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_returns_failure(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileMetricExporter(mock_stream) + result = exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.FAILURE) + + def test_export_stream_error_logs_exception(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileMetricExporter(mock_stream) + with self.assertLogs(_LOGGER_NAME, level="ERROR"): + exporter.export(_make_metrics_data()) + + +class TestFileMetricExporterTemporality(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + + def _exporter(self, **kwargs) -> FileMetricExporter: + return FileMetricExporter(self._stream, **kwargs) + + def test_temporality_default_is_cumulative(self): + exporter = self._exporter() + for instrument_class in ( + Counter, + UpDownCounter, + Histogram, + ObservableCounter, + ObservableUpDownCounter, + ObservableGauge, + ): + with self.subTest(instrument=instrument_class.__name__): + self.assertEqual( + exporter._preferred_temporality[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"}, + ): + exporter = self._exporter() + for instrument_class, expected in delta_cases.items(): + with self.subTest(instrument=instrument_class.__name__): + self.assertEqual( + exporter._preferred_temporality[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"}, + ): + exporter = self._exporter() + for instrument_class, expected in lowmemory_cases.items(): + with self.subTest(instrument=instrument_class.__name__): + self.assertEqual( + exporter._preferred_temporality[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"): + exporter = self._exporter() + self.assertEqual( + exporter._preferred_temporality[Counter], + AggregationTemporality.CUMULATIVE, + ) + + def test_temporality_constructor_overrides_env(self): + with patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "CUMULATIVE"}, + ): + exporter = self._exporter( + preferred_temporality={Counter: AggregationTemporality.DELTA} + ) + self.assertEqual( + exporter._preferred_temporality[Counter], + AggregationTemporality.DELTA, + ) + + +class TestFileMetricExporterAggregation(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + + def _exporter(self, **kwargs) -> FileMetricExporter: + return FileMetricExporter(self._stream, **kwargs) + + def test_aggregation_default_is_explicit_bucket(self): + exporter = self._exporter() + self.assertIsInstance( + exporter._preferred_aggregation[Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_aggregation_exponential_env(self): + with patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "base2_exponential_bucket_histogram" + }, + ): + exporter = self._exporter() + self.assertIsInstance( + exporter._preferred_aggregation[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"): + exporter = self._exporter() + self.assertIsInstance( + exporter._preferred_aggregation[Histogram], + ExplicitBucketHistogramAggregation, + ) + + def test_aggregation_constructor_overrides_env(self): + custom_aggregation = ExponentialBucketHistogramAggregation() + with patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "explicit_bucket_histogram" + }, + ): + exporter = self._exporter( + preferred_aggregation={Histogram: custom_aggregation} + ) + self.assertIs( + exporter._preferred_aggregation[Histogram], + custom_aggregation, + ) + + +class TestFileMetricExporterIntegration(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._file_exporter = FileMetricExporter(self._stream) + self._in_memory = InMemoryMetricReader() + self._provider = MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader( + self._file_exporter, export_interval_millis=100_000 + ), + self._in_memory, + ] + ) + self._meter = self._provider.get_meter(__name__) + + def tearDown(self): + self._provider.shutdown() + + def _metric_names_and_values( + self, metrics_data + ) -> dict[str, list[int | float]]: + result: dict[str, list[int | float]] = {} + for rm in metrics_data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + result[m.name] = [ + getattr(dp, "value", getattr(dp, "sum", None)) + for dp in m.data.data_points + ] + return result + + def test_counter_matches_in_memory(self): + counter = self._meter.create_counter("requests") + counter.add(42) + self._provider.force_flush() + metrics_data = self._in_memory.get_metrics_data() + + self.assertIn("requests", self._stream.getvalue()) + in_memory_nv = self._metric_names_and_values(metrics_data) + self.assertIn("requests", in_memory_nv) + self.assertEqual(in_memory_nv["requests"], [42]) + + def test_histogram_matches_in_memory(self): + histogram = self._meter.create_histogram("latency") + histogram.record(1.5) + histogram.record(3.0) + self._provider.force_flush() + metrics_data = self._in_memory.get_metrics_data() + + self.assertIn("latency", self._stream.getvalue()) + in_memory_nv = self._metric_names_and_values(metrics_data) + self.assertIn("latency", in_memory_nv) + self.assertEqual(in_memory_nv["latency"], [4.5]) + + def test_stream_output_is_valid_json(self): + counter = self._meter.create_counter("ops") + counter.add(1) + self._provider.force_flush() + for line in self._stream.getvalue().splitlines(): + json.loads(line) 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 index e69de29bb2d..fd95eb7e0b1 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py @@ -0,0 +1,159 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import io +import json +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.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, + SpanExportResult, +) +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.file.trace_exporter" + + +class TestFileSpanExporter(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._exporter = FileSpanExporter(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_returns_success(self): + result = self._exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.SUCCESS) + + def test_export_single_span_writes_one_json_line(self): + self._exporter.export(self._make_span()) + lines = self._stream.getvalue().splitlines() + self.assertEqual(len(lines), 1) + json.loads(lines[0]) # must be valid JSON + + def test_export_span_name_in_output(self): + self._exporter.export(self._make_span("my-span")) + self.assertIn("my-span", self._stream.getvalue()) + + def test_export_multiple_spans_same_resource_writes_one_line(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) + data = json.loads(lines[0]) + total_spans = sum(len(ss["spans"]) for ss in data["scopeSpans"]) + self.assertEqual(total_spans, 2) + + def test_stream_flushed_after_export(self): + mock_stream = Mock() + exporter = FileSpanExporter(mock_stream) + exporter.export(self._make_span()) + mock_stream.flush.assert_called_once() + + def test_custom_formatter_called(self): + formatter = Mock(return_value="formatted\n") + exporter = FileSpanExporter(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_returns_failure(self): + self._exporter.shutdown() + result = self._exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.FAILURE) + + def test_export_after_shutdown_logs_warning(self): + self._exporter.shutdown() + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + self._exporter.export([]) + + def test_export_after_shutdown_writes_nothing(self): + self._exporter.shutdown() + self._exporter.export(self._make_span()) + self.assertEqual(self._stream.getvalue(), "") + + def test_shutdown_idempotent_logs_warning(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_force_flush_returns_true_after_export(self): + self._exporter.export(self._make_span()) + self.assertTrue(self._exporter.force_flush()) + + def test_export_stream_error_returns_failure(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileSpanExporter(mock_stream) + result = exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.FAILURE) + + def test_export_stream_error_logs_exception(self): + mock_stream = Mock() + mock_stream.writelines.side_effect = OSError("disk full") + exporter = FileSpanExporter(mock_stream) + with self.assertLogs(_LOGGER_NAME, level="ERROR"): + exporter.export(self._make_span()) + + +class TestFileSpanExporterIntegration(unittest.TestCase): + def setUp(self): + self._stream = io.StringIO() + self._file_exporter = FileSpanExporter(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 + ) + + def test_single_span_matches_in_memory(self): + with self._tracer.start_as_current_span("span-a"): + pass + self.assertEqual(self._stream.getvalue(), self._expected()) + + def test_multiple_spans_match_in_memory(self): + with self._tracer.start_as_current_span("span-a"): + pass + with self._tracer.start_as_current_span("span-b"): + pass + self.assertEqual(self._stream.getvalue(), self._expected()) From 36bb3bc4e6a31d6c12d3445a1594930480b266f7 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 15 May 2026 22:38:23 -0400 Subject: [PATCH 7/9] simplify tests --- .../exporter/otlp/json/file/_internal.py | 108 +++++++++ .../otlp/json/file/metric_exporter.py | 108 +-------- .../tests/test_internal.py | 147 +++++++++++- .../tests/test_log_exporter.py | 56 ++--- .../tests/test_metric_exporter.py | 210 +----------------- .../tests/test_trace_exporter.py | 46 +--- 6 files changed, 300 insertions(+), 375 deletions(-) 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 index ffca32f97d2..53ae86195c7 100644 --- 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 @@ -2,7 +2,115 @@ # 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/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-file/src/opentelemetry/exporter/otlp/json/file/metric_exporter.py index b5fe10b5722..4a9f7c92cf8 100644 --- 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 @@ -3,24 +3,15 @@ import logging from collections.abc import Callable -from os import environ from typing import IO from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( encode_metrics, ) -from opentelemetry.exporter.otlp.json.file._internal import _format_line -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.exporter.otlp.json.file._internal import ( + _format_line, + _get_aggregation, + _get_temporality, ) from opentelemetry.sdk.metrics.export import ( AggregationTemporality, @@ -28,11 +19,7 @@ MetricExportResult, MetricsData, ) -from opentelemetry.sdk.metrics.view import ( - Aggregation, - ExplicitBucketHistogramAggregation, - ExponentialBucketHistogramAggregation, -) +from opentelemetry.sdk.metrics.view import Aggregation _logger = logging.getLogger(__name__) @@ -92,88 +79,3 @@ def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: def force_flush(self, timeout_millis: float = 10_000) -> bool: """Nothing is buffered in this exporter, so this method does nothing.""" return True - - -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/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py index a4edd4ebf35..3b637973966 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py @@ -5,8 +5,32 @@ import json import unittest +from unittest.mock import patch -from opentelemetry.exporter.otlp.json.file._internal import _format_line +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): @@ -28,3 +52,124 @@ def test_nested_structure(self): 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 index ae66338fc9e..de635fadbfe 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py @@ -7,9 +7,8 @@ import json import logging import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock -import opentelemetry.exporter.otlp.json.file._log_exporter as _log_exporter_mod 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 @@ -29,6 +28,8 @@ 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", @@ -55,21 +56,15 @@ def test_export_empty_sequence(self): self.assertEqual(result, LogRecordExportResult.SUCCESS) self.assertEqual(self._stream.getvalue(), "") - def test_export_single_log_returns_success(self): - result = self._exporter.export([_make_log_record()]) + def test_export_single_log(self): + result = self._exporter.export([_make_log_record("hello from test")]) self.assertEqual(result, LogRecordExportResult.SUCCESS) - - def test_export_single_log_writes_one_json_line(self): - self._exporter.export([_make_log_record()]) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) - json.loads(lines[0]) # must be valid JSON - - def test_export_log_body_in_output(self): - self._exporter.export([_make_log_record("hello from test")]) + json.loads(lines[0]) self.assertIn("hello from test", self._stream.getvalue()) - def test_export_multiple_logs_same_resource_writes_one_line(self): + def test_export_multiple_logs_same_resource(self): logs = [ _make_log_record("first"), _make_log_record("second"), @@ -81,7 +76,7 @@ def test_export_multiple_logs_same_resource_writes_one_line(self): total_logs = sum(len(sl["logRecords"]) for sl in data["scopeLogs"]) self.assertEqual(total_logs, 2) - def test_export_logs_different_resources_writes_multiple_lines(self): + 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"}), @@ -96,53 +91,36 @@ def test_stream_flushed_after_export(self): exporter.export([_make_log_record()]) mock_stream.flush.assert_called_once() - def test_custom_formatter_called(self): + def test_custom_formatter(self): formatter = Mock(return_value="formatted\n") exporter = FileLogExporter(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_returns_failure(self): + def test_export_after_shutdown(self): self._exporter.shutdown() - result = self._exporter.export([_make_log_record()]) + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = self._exporter.export([_make_log_record()]) self.assertEqual(result, LogRecordExportResult.FAILURE) - - def test_export_after_shutdown_logs_warning(self): - self._exporter.shutdown() - with patch.object(_log_exporter_mod._logger, "warning") as mock_warn: - self._exporter.export([]) - mock_warn.assert_called_once() - - def test_export_after_shutdown_writes_nothing(self): - self._exporter.shutdown() - self._exporter.export([_make_log_record()]) self.assertEqual(self._stream.getvalue(), "") - def test_shutdown_idempotent_logs_warning(self): + def test_shutdown_idempotent(self): self._exporter.shutdown() - with patch.object(_log_exporter_mod._logger, "warning") as mock_warn: + with self.assertLogs(_LOGGER_NAME, level="WARNING"): self._exporter.shutdown() - mock_warn.assert_called_once() def test_force_flush_returns_true(self): self.assertTrue(self._exporter.force_flush()) - def test_export_stream_error_returns_failure(self): + def test_export_stream_error(self): mock_stream = Mock() mock_stream.writelines.side_effect = OSError("disk full") exporter = FileLogExporter(mock_stream) - result = exporter.export([_make_log_record()]) + with self.assertLogs(_LOGGER_NAME, level="ERROR"): + result = exporter.export([_make_log_record()]) self.assertEqual(result, LogRecordExportResult.FAILURE) - def test_export_stream_error_logs_exception(self): - mock_stream = Mock() - mock_stream.writelines.side_effect = OSError("disk full") - exporter = FileLogExporter(mock_stream) - with patch.object(_log_exporter_mod._logger, "exception") as mock_exc: - exporter.export([_make_log_record()]) - mock_exc.assert_called_once() - class TestFileLogExporterIntegration(unittest.TestCase): def setUp(self): 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 index 3723265cbcb..e376d4a25d7 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py @@ -6,29 +6,12 @@ import io import json import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock from opentelemetry.exporter.otlp.json.file.metric_exporter import ( FileMetricExporter, ) -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, - MeterProvider, - ObservableCounter, - ObservableGauge, - ObservableUpDownCounter, - UpDownCounter, -) -from opentelemetry.sdk.metrics._internal.aggregation import ( - AggregationTemporality, - ExplicitBucketHistogramAggregation, - ExponentialBucketHistogramAggregation, -) +from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics._internal.export import MetricExportResult from opentelemetry.sdk.metrics._internal.point import ( MetricsData, @@ -69,18 +52,12 @@ def setUp(self): self._stream = io.StringIO() self._exporter = FileMetricExporter(self._stream) - def test_export_metrics_returns_success(self): + def test_export_metrics(self): result = self._exporter.export(_make_metrics_data()) self.assertEqual(result, MetricExportResult.SUCCESS) - - def test_export_metrics_writes_valid_json(self): - self._exporter.export(_make_metrics_data()) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) json.loads(lines[0]) - - def test_export_metric_name_in_output(self): - self._exporter.export(_make_metrics_data()) self.assertIn("requests", self._stream.getvalue()) def test_stream_flushed_after_export(self): @@ -89,14 +66,14 @@ def test_stream_flushed_after_export(self): exporter.export(_make_metrics_data()) mock_stream.flush.assert_called_once() - def test_custom_formatter_called(self): + def test_custom_formatter(self): formatter = Mock(return_value="formatted\n") exporter = FileMetricExporter(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_writes_one_line_each(self): + def test_export_multiple_resource_metrics(self): data = MetricsData( resource_metrics=[ ResourceMetrics( @@ -127,22 +104,14 @@ def test_export_multiple_resource_metrics_writes_one_line_each(self): lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 2) - def test_export_after_shutdown_returns_failure(self): - self._exporter.shutdown() - result = self._exporter.export(_make_metrics_data()) - self.assertEqual(result, MetricExportResult.FAILURE) - - def test_export_after_shutdown_logs_warning(self): + def test_export_after_shutdown(self): self._exporter.shutdown() with self.assertLogs(_LOGGER_NAME, level="WARNING"): - self._exporter.export(_make_metrics_data()) - - def test_export_after_shutdown_writes_nothing(self): - self._exporter.shutdown() - self._exporter.export(_make_metrics_data()) + result = self._exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.FAILURE) self.assertEqual(self._stream.getvalue(), "") - def test_shutdown_idempotent_logs_warning(self): + def test_shutdown_idempotent(self): self._exporter.shutdown() with self.assertLogs(_LOGGER_NAME, level="WARNING"): self._exporter.shutdown() @@ -150,168 +119,13 @@ def test_shutdown_idempotent_logs_warning(self): def test_force_flush_returns_true(self): self.assertTrue(self._exporter.force_flush()) - def test_export_stream_error_returns_failure(self): - mock_stream = Mock() - mock_stream.writelines.side_effect = OSError("disk full") - exporter = FileMetricExporter(mock_stream) - result = exporter.export(_make_metrics_data()) - self.assertEqual(result, MetricExportResult.FAILURE) - - def test_export_stream_error_logs_exception(self): + def test_export_stream_error(self): mock_stream = Mock() mock_stream.writelines.side_effect = OSError("disk full") exporter = FileMetricExporter(mock_stream) with self.assertLogs(_LOGGER_NAME, level="ERROR"): - exporter.export(_make_metrics_data()) - - -class TestFileMetricExporterTemporality(unittest.TestCase): - def setUp(self): - self._stream = io.StringIO() - - def _exporter(self, **kwargs) -> FileMetricExporter: - return FileMetricExporter(self._stream, **kwargs) - - def test_temporality_default_is_cumulative(self): - exporter = self._exporter() - for instrument_class in ( - Counter, - UpDownCounter, - Histogram, - ObservableCounter, - ObservableUpDownCounter, - ObservableGauge, - ): - with self.subTest(instrument=instrument_class.__name__): - self.assertEqual( - exporter._preferred_temporality[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"}, - ): - exporter = self._exporter() - for instrument_class, expected in delta_cases.items(): - with self.subTest(instrument=instrument_class.__name__): - self.assertEqual( - exporter._preferred_temporality[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"}, - ): - exporter = self._exporter() - for instrument_class, expected in lowmemory_cases.items(): - with self.subTest(instrument=instrument_class.__name__): - self.assertEqual( - exporter._preferred_temporality[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"): - exporter = self._exporter() - self.assertEqual( - exporter._preferred_temporality[Counter], - AggregationTemporality.CUMULATIVE, - ) - - def test_temporality_constructor_overrides_env(self): - with patch.dict( - "os.environ", - {OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "CUMULATIVE"}, - ): - exporter = self._exporter( - preferred_temporality={Counter: AggregationTemporality.DELTA} - ) - self.assertEqual( - exporter._preferred_temporality[Counter], - AggregationTemporality.DELTA, - ) - - -class TestFileMetricExporterAggregation(unittest.TestCase): - def setUp(self): - self._stream = io.StringIO() - - def _exporter(self, **kwargs) -> FileMetricExporter: - return FileMetricExporter(self._stream, **kwargs) - - def test_aggregation_default_is_explicit_bucket(self): - exporter = self._exporter() - self.assertIsInstance( - exporter._preferred_aggregation[Histogram], - ExplicitBucketHistogramAggregation, - ) - - def test_aggregation_exponential_env(self): - with patch.dict( - "os.environ", - { - OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "base2_exponential_bucket_histogram" - }, - ): - exporter = self._exporter() - self.assertIsInstance( - exporter._preferred_aggregation[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"): - exporter = self._exporter() - self.assertIsInstance( - exporter._preferred_aggregation[Histogram], - ExplicitBucketHistogramAggregation, - ) - - def test_aggregation_constructor_overrides_env(self): - custom_aggregation = ExponentialBucketHistogramAggregation() - with patch.dict( - "os.environ", - { - OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: "explicit_bucket_histogram" - }, - ): - exporter = self._exporter( - preferred_aggregation={Histogram: custom_aggregation} - ) - self.assertIs( - exporter._preferred_aggregation[Histogram], - custom_aggregation, - ) + result = exporter.export(_make_metrics_data()) + self.assertEqual(result, MetricExportResult.FAILURE) class TestFileMetricExporterIntegration(unittest.TestCase): 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 index fd95eb7e0b1..73eed30b510 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py @@ -48,21 +48,15 @@ def test_export_empty_sequence(self): self.assertEqual(result, SpanExportResult.SUCCESS) self.assertEqual(self._stream.getvalue(), "") - def test_export_single_span_returns_success(self): - result = self._exporter.export(self._make_span()) + def test_export_single_span(self): + result = self._exporter.export(self._make_span("my-span")) self.assertEqual(result, SpanExportResult.SUCCESS) - - def test_export_single_span_writes_one_json_line(self): - self._exporter.export(self._make_span()) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) - json.loads(lines[0]) # must be valid JSON - - def test_export_span_name_in_output(self): - self._exporter.export(self._make_span("my-span")) + json.loads(lines[0]) self.assertIn("my-span", self._stream.getvalue()) - def test_export_multiple_spans_same_resource_writes_one_line(self): + 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"): @@ -80,53 +74,37 @@ def test_stream_flushed_after_export(self): exporter.export(self._make_span()) mock_stream.flush.assert_called_once() - def test_custom_formatter_called(self): + def test_custom_formatter(self): formatter = Mock(return_value="formatted\n") exporter = FileSpanExporter(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_returns_failure(self): - self._exporter.shutdown() - result = self._exporter.export(self._make_span()) - self.assertEqual(result, SpanExportResult.FAILURE) - - def test_export_after_shutdown_logs_warning(self): + def test_export_after_shutdown(self): self._exporter.shutdown() with self.assertLogs(_LOGGER_NAME, level="WARNING"): - self._exporter.export([]) - - def test_export_after_shutdown_writes_nothing(self): - self._exporter.shutdown() - self._exporter.export(self._make_span()) + result = self._exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.FAILURE) self.assertEqual(self._stream.getvalue(), "") - def test_shutdown_idempotent_logs_warning(self): + 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_force_flush_returns_true_after_export(self): self._exporter.export(self._make_span()) self.assertTrue(self._exporter.force_flush()) - def test_export_stream_error_returns_failure(self): - mock_stream = Mock() - mock_stream.writelines.side_effect = OSError("disk full") - exporter = FileSpanExporter(mock_stream) - result = exporter.export(self._make_span()) - self.assertEqual(result, SpanExportResult.FAILURE) - - def test_export_stream_error_logs_exception(self): + def test_export_stream_error(self): mock_stream = Mock() mock_stream.writelines.side_effect = OSError("disk full") exporter = FileSpanExporter(mock_stream) with self.assertLogs(_LOGGER_NAME, level="ERROR"): - exporter.export(self._make_span()) + result = exporter.export(self._make_span()) + self.assertEqual(result, SpanExportResult.FAILURE) class TestFileSpanExporterIntegration(unittest.TestCase): From 7b8199792cf5c2e259527e9dfa2039944646fd86 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sat, 16 May 2026 00:14:09 -0400 Subject: [PATCH 8/9] test improvements --- .../pyproject.toml | 9 ++ .../exporter/otlp/json/file/_log_exporter.py | 49 +++++- .../otlp/json/file/metric_exporter.py | 52 ++++++- .../exporter/otlp/json/file/trace_exporter.py | 45 +++++- .../tests/test_internal.py | 2 - .../tests/test_log_exporter.py | 93 ++++++++---- .../tests/test_metric_exporter.py | 142 +++++++++++------- .../tests/test_trace_exporter.py | 88 ++++++++--- 8 files changed, 367 insertions(+), 113 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml index 50df8c63b12..1112e1934be 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml +++ b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml @@ -30,6 +30,15 @@ dependencies = [ "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" 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 index 7e82ba74e96..d7734f90e06 100644 --- 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 @@ -1,9 +1,13 @@ # 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 typing import IO +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 @@ -20,12 +24,47 @@ 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: - self._stream = stream + 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(path, "a") + 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 @@ -57,7 +96,5 @@ def shutdown(self) -> None: _logger.warning("Exporter already shutdown, ignoring call") return self._shutdown = True - - def force_flush(self, timeout_millis: int = 30000) -> bool: - """Nothing is buffered in this exporter, so this method does nothing.""" - return 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 index 4a9f7c92cf8..47e1649bd6d 100644 --- 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 @@ -2,8 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 import logging +import sys from collections.abc import Callable -from typing import IO +from os import PathLike +from typing import IO, overload from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( encode_metrics, @@ -25,20 +27,64 @@ 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), ) - self._stream = stream + if path is not None: + self._stream: IO[str] = open(path, "a") + 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 @@ -75,6 +121,8 @@ def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: _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.""" 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 index 11806341345..ab2236a8fe4 100644 --- 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 @@ -1,9 +1,13 @@ # 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 typing import IO +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 @@ -14,12 +18,47 @@ 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: - self._stream = stream + 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(path, "a") + 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 @@ -50,6 +89,8 @@ def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: _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.""" diff --git a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py index 3b637973966..339cd44f63c 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_internal.py @@ -1,8 +1,6 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - import json import unittest from unittest.mock import patch 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 index de635fadbfe..f40766abb87 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py @@ -1,11 +1,10 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - import io -import json -import logging +import os +import sys +import tempfile import unittest from unittest.mock import Mock @@ -15,9 +14,9 @@ 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, - LoggingHandler, ReadableLogRecord, ) from opentelemetry.sdk._logs.export import ( @@ -49,7 +48,7 @@ def _make_log_record( class TestFileLogExporter(unittest.TestCase): def setUp(self): self._stream = io.StringIO() - self._exporter = FileLogExporter(self._stream) + self._exporter = FileLogExporter(stream=self._stream) def test_export_empty_sequence(self): result = self._exporter.export([]) @@ -61,8 +60,11 @@ def test_export_single_log(self): self.assertEqual(result, LogRecordExportResult.SUCCESS) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) - json.loads(lines[0]) - self.assertIn("hello from test", self._stream.getvalue()) + rl = ResourceLogs.from_json(lines[0]) + self.assertEqual( + rl.scope_logs[0].log_records[0].body.string_value, # type: ignore + "hello from test", + ) def test_export_multiple_logs_same_resource(self): logs = [ @@ -72,8 +74,8 @@ def test_export_multiple_logs_same_resource(self): self._exporter.export(logs) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) - data = json.loads(lines[0]) - total_logs = sum(len(sl["logRecords"]) for sl in data["scopeLogs"]) + rl = ResourceLogs.from_json(lines[0]) + total_logs = sum(len(sl.log_records) for sl in rl.scope_logs) self.assertEqual(total_logs, 2) def test_export_logs_different_resources(self): @@ -84,16 +86,24 @@ def test_export_logs_different_resources(self): self._exporter.export(logs) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 2) + bodies = { + ResourceLogs.from_json(line) + .scope_logs[0] + .log_records[0] + .body.string_value # type: ignore + for line in lines + } + self.assertEqual(bodies, {"from-a", "from-b"}) def test_stream_flushed_after_export(self): mock_stream = Mock() - exporter = FileLogExporter(mock_stream) + 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(self._stream, formatter=formatter) + exporter = FileLogExporter(stream=self._stream, formatter=formatter) exporter.export([_make_log_record()]) formatter.assert_called_once() self.assertIn("formatted\n", self._stream.getvalue()) @@ -110,22 +120,41 @@ def test_shutdown_idempotent(self): 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 = FileLogExporter(mock_stream) + 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): + 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) as f: + rl = ResourceLogs.from_json(f.read().splitlines()[0]) + self.assertEqual( + rl.scope_logs[0].log_records[0].body.string_value, # type: ignore + "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() + self.assertIs(exporter._stream, sys.stdout) -class TestFileLogExporterIntegration(unittest.TestCase): + +class TestFileLogExporterRoundTrip(unittest.TestCase): def setUp(self): self._stream = io.StringIO() - self._file_exporter = FileLogExporter(self._stream) + self._file_exporter = FileLogExporter(stream=self._stream) self._in_memory = InMemoryLogRecordExporter() provider = LoggerProvider() provider.add_log_record_processor( @@ -134,14 +163,7 @@ def setUp(self): provider.add_log_record_processor( SimpleLogRecordProcessor(self._in_memory) ) - handler = LoggingHandler(logger_provider=provider) - self._logger = logging.getLogger("test.file.log.exporter.integration") - self._logger.addHandler(handler) - self._logger.setLevel(logging.DEBUG) - - def tearDown(self): - for h in self._logger.handlers[:]: - self._logger.removeHandler(h) + self._logger = provider.get_logger("test.integration") def _expected(self) -> str: return "".join( @@ -151,10 +173,23 @@ def _expected(self) -> str: ) def test_single_log_matches_in_memory(self): - self._logger.info("hello from integration") + 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.info("first message") - self._logger.warning("second message") + 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 index e376d4a25d7..9fac5cbd3a7 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py @@ -1,16 +1,25 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - import io -import json +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 ( @@ -19,7 +28,6 @@ ScopeMetrics, ) from opentelemetry.sdk.metrics.export import ( - InMemoryMetricReader, PeriodicExportingMetricReader, ) from opentelemetry.sdk.resources import Resource @@ -29,6 +37,23 @@ _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=[ @@ -50,25 +75,25 @@ def _make_metrics_data() -> MetricsData: class TestFileMetricExporter(unittest.TestCase): def setUp(self): self._stream = io.StringIO() - self._exporter = FileMetricExporter(self._stream) + 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) - json.loads(lines[0]) - self.assertIn("requests", self._stream.getvalue()) + rm = OtlpResourceMetrics.from_json(lines[0]) + self.assertEqual(rm.scope_metrics[0].metrics[0].name, "requests") def test_stream_flushed_after_export(self): mock_stream = Mock() - exporter = FileMetricExporter(mock_stream) + 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(self._stream, formatter=formatter) + exporter = FileMetricExporter(stream=self._stream, formatter=formatter) exporter.export(_make_metrics_data()) formatter.assert_called_once() self.assertIn("formatted\n", self._stream.getvalue()) @@ -103,6 +128,14 @@ def test_export_multiple_resource_metrics(self): self._exporter.export(data) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 2) + names = { + OtlpResourceMetrics.from_json(line) + .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() @@ -122,23 +155,40 @@ def test_force_flush_returns_true(self): def test_export_stream_error(self): mock_stream = Mock() mock_stream.writelines.side_effect = OSError("disk full") - exporter = FileMetricExporter(mock_stream) + 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): + 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) as f: + rm = OtlpResourceMetrics.from_json(f.read().splitlines()[0]) + 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() + self.assertIs(exporter._stream, sys.stdout) -class TestFileMetricExporterIntegration(unittest.TestCase): + +class TestFileMetricExporterRoundTrip(unittest.TestCase): def setUp(self): self._stream = io.StringIO() - self._file_exporter = FileMetricExporter(self._stream) - self._in_memory = InMemoryMetricReader() + self._exporter = _CapturingMetricExporter(stream=self._stream) self._provider = MeterProvider( metric_readers=[ PeriodicExportingMetricReader( - self._file_exporter, export_interval_millis=100_000 + self._exporter, export_interval_millis=100_000 ), - self._in_memory, ] ) self._meter = self._provider.get_meter(__name__) @@ -146,45 +196,31 @@ def setUp(self): def tearDown(self): self._provider.shutdown() - def _metric_names_and_values( - self, metrics_data - ) -> dict[str, list[int | float]]: - result: dict[str, list[int | float]] = {} - for rm in metrics_data.resource_metrics: - for sm in rm.scope_metrics: - for m in sm.metrics: - result[m.name] = [ - getattr(dp, "value", getattr(dp, "sum", None)) - for dp in m.data.data_points - ] - return result - - def test_counter_matches_in_memory(self): - counter = self._meter.create_counter("requests") - counter.add(42) - self._provider.force_flush() - metrics_data = self._in_memory.get_metrics_data() - - self.assertIn("requests", self._stream.getvalue()) - in_memory_nv = self._metric_names_and_values(metrics_data) - self.assertIn("requests", in_memory_nv) - self.assertEqual(in_memory_nv["requests"], [42]) + def _expected(self) -> str: + return "".join( + _format_line(rm.to_dict()) + for rm in encode_metrics( + self._exporter.last_metrics_data # type: ignore + ).resource_metrics + ) - def test_histogram_matches_in_memory(self): - histogram = self._meter.create_histogram("latency") - histogram.record(1.5) - histogram.record(3.0) + 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() - metrics_data = self._in_memory.get_metrics_data() + self.assertEqual(self._stream.getvalue(), self._expected()) - self.assertIn("latency", self._stream.getvalue()) - in_memory_nv = self._metric_names_and_values(metrics_data) - self.assertIn("latency", in_memory_nv) - self.assertEqual(in_memory_nv["latency"], [4.5]) - - def test_stream_output_is_valid_json(self): - counter = self._meter.create_counter("ops") - counter.add(1) + 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() - for line in self._stream.getvalue().splitlines(): - json.loads(line) + 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 index 73eed30b510..016c2b0402e 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py @@ -1,10 +1,10 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - import io -import json +import os +import sys +import tempfile import unittest from unittest.mock import Mock @@ -13,6 +13,7 @@ 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, @@ -21,6 +22,7 @@ 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" @@ -28,7 +30,7 @@ class TestFileSpanExporter(unittest.TestCase): def setUp(self): self._stream = io.StringIO() - self._exporter = FileSpanExporter(self._stream) + self._exporter = FileSpanExporter(stream=self._stream) self._in_memory = InMemorySpanExporter() provider = TracerProvider() @@ -53,8 +55,8 @@ def test_export_single_span(self): self.assertEqual(result, SpanExportResult.SUCCESS) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) - json.loads(lines[0]) - self.assertIn("my-span", self._stream.getvalue()) + rs = ResourceSpans.from_json(lines[0]) + 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"): @@ -64,19 +66,19 @@ def test_export_multiple_spans_same_resource(self): self._exporter.export(self._finished_spans()) lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 1) - data = json.loads(lines[0]) - total_spans = sum(len(ss["spans"]) for ss in data["scopeSpans"]) + rs = ResourceSpans.from_json(lines[0]) + total_spans = sum(len(ss.spans) for ss in rs.scope_spans) self.assertEqual(total_spans, 2) def test_stream_flushed_after_export(self): mock_stream = Mock() - exporter = FileSpanExporter(mock_stream) + 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(self._stream, formatter=formatter) + exporter = FileSpanExporter(stream=self._stream, formatter=formatter) exporter.export(self._make_span()) formatter.assert_called_once() self.assertIn("formatted\n", self._stream.getvalue()) @@ -101,16 +103,35 @@ def test_force_flush_returns_true(self): def test_export_stream_error(self): mock_stream = Mock() mock_stream.writelines.side_effect = OSError("disk full") - exporter = FileSpanExporter(mock_stream) + 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): + 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) as f: + rs = ResourceSpans.from_json(f.read().splitlines()[0]) + 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() + self.assertIs(exporter._stream, sys.stdout) -class TestFileSpanExporterIntegration(unittest.TestCase): + +class TestFileSpanExporterRoundTrip(unittest.TestCase): def setUp(self): self._stream = io.StringIO() - self._file_exporter = FileSpanExporter(self._stream) + self._file_exporter = FileSpanExporter(stream=self._stream) self._in_memory = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(self._file_exporter)) @@ -125,13 +146,42 @@ def _expected(self) -> str: ) def test_single_span_matches_in_memory(self): - with self._tracer.start_as_current_span("span-a"): - pass + 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("span-a"): - pass - with self._tracer.start_as_current_span("span-b"): - pass + 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()) From 5f88b296b830325f2b5a48814e5ac73d03681233 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sat, 16 May 2026 00:27:33 -0400 Subject: [PATCH 9/9] fix lint errors --- .github/workflows/lint.yml | 19 ++ .github/workflows/test.yml | 280 ++++++++++++++++++ eachdist.ini | 1 + .../exporter/otlp/json/file/_log_exporter.py | 4 +- .../otlp/json/file/metric_exporter.py | 4 +- .../exporter/otlp/json/file/trace_exporter.py | 4 +- .../tests/test_log_exporter.py | 17 +- .../tests/test_metric_exporter.py | 13 +- .../tests/test_trace_exporter.py | 12 +- 9 files changed, 336 insertions(+), 18 deletions(-) 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/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 index d7734f90e06..7da8b0b6cdb 100644 --- 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 @@ -57,7 +57,9 @@ def __init__( 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(path, "a") + 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 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 index 47e1649bd6d..3afc4f1662a 100644 --- 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 @@ -77,7 +77,9 @@ def __init__( preferred_aggregation=_get_aggregation(preferred_aggregation), ) if path is not None: - self._stream: IO[str] = open(path, "a") + 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 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 index ab2236a8fe4..e19c9814511 100644 --- 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 @@ -51,7 +51,9 @@ def __init__( 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(path, "a") + 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 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 index f40766abb87..57059c96169 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_log_exporter.py @@ -62,7 +62,7 @@ def test_export_single_log(self): 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 + rl.scope_logs[0].log_records[0].body.string_value, # type: ignore # pylint: disable=unsubscriptable-object "hello from test", ) @@ -75,7 +75,7 @@ def test_export_multiple_logs_same_resource(self): 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) + 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): @@ -87,7 +87,7 @@ def test_export_logs_different_resources(self): lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 2) bodies = { - ResourceLogs.from_json(line) + ResourceLogs.from_json(line) # pylint: disable=unsubscriptable-object .scope_logs[0] .log_records[0] .body.string_value # type: ignore @@ -95,6 +95,7 @@ def test_export_logs_different_resources(self): } 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) @@ -129,15 +130,16 @@ def test_export_stream_error(self): 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) as f: - rl = ResourceLogs.from_json(f.read().splitlines()[0]) + 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 + rl.scope_logs[0].log_records[0].body.string_value, # type: ignore # pylint: disable=unsubscriptable-object "hello from path", ) tmp_dir.cleanup() @@ -148,6 +150,7 @@ def test_path_and_stream_raises(self): def test_default_stream_is_stdout(self): exporter = FileLogExporter() + # pylint: disable-next=protected-access self.assertIs(exporter._stream, sys.stdout) @@ -169,7 +172,7 @@ 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 + for rl in encode_logs([record]).resource_logs # pylint: disable=not-an-iterable ) def test_single_log_matches_in_memory(self): 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 index 9fac5cbd3a7..243fc53e505 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_metric_exporter.py @@ -83,8 +83,10 @@ def test_export_metrics(self): 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) @@ -129,7 +131,7 @@ def test_export_multiple_resource_metrics(self): lines = self._stream.getvalue().splitlines() self.assertEqual(len(lines), 2) names = { - OtlpResourceMetrics.from_json(line) + OtlpResourceMetrics.from_json(line) # pylint: disable=unsubscriptable-object .scope_metrics[0] .metrics[0] .name @@ -161,13 +163,15 @@ def test_export_stream_error(self): 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) as f: - rm = OtlpResourceMetrics.from_json(f.read().splitlines()[0]) + 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() @@ -177,6 +181,7 @@ def test_path_and_stream_raises(self): def test_default_stream_is_stdout(self): exporter = FileMetricExporter() + # pylint: disable-next=protected-access self.assertIs(exporter._stream, sys.stdout) @@ -199,7 +204,7 @@ def tearDown(self): def _expected(self) -> str: return "".join( _format_line(rm.to_dict()) - for rm in encode_metrics( + for rm in encode_metrics( # pylint: disable=not-an-iterable self._exporter.last_metrics_data # type: ignore ).resource_metrics ) 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 index 016c2b0402e..7d22581feec 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-file/tests/test_trace_exporter.py @@ -56,6 +56,7 @@ def test_export_single_span(self): 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): @@ -67,7 +68,7 @@ def test_export_multiple_spans_same_resource(self): 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) + 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): @@ -109,13 +110,15 @@ def test_export_stream_error(self): 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) as f: - rs = ResourceSpans.from_json(f.read().splitlines()[0]) + 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() @@ -125,6 +128,7 @@ def test_path_and_stream_raises(self): def test_default_stream_is_stdout(self): exporter = FileSpanExporter() + # pylint: disable-next=protected-access self.assertIs(exporter._stream, sys.stdout) @@ -142,7 +146,7 @@ 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 + for rs in encode_spans([span]).resource_spans # pylint: disable=not-an-iterable ) def test_single_span_matches_in_memory(self):