From 1c3bbf998b555d2e9f11c5da99bbe09aff39553a Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 19:53:17 -0500 Subject: [PATCH 01/20] add initial scaffolding --- .../LICENSE | 201 ++++++++++++++++++ .../README.rst | 30 +++ .../pyproject.toml | 59 +++++ .../exporter/otlp/json/http/__init__.py | 14 ++ .../exporter/otlp/json/http/_internal.py | 2 + .../exporter/otlp/json/http/_log_exporter.py | 28 +++ .../otlp/json/http/metric_exporter.py | 28 +++ .../exporter/otlp/json/http/py.typed | 0 .../exporter/otlp/json/http/trace_exporter.py | 21 ++ .../otlp/json/http/version/__init__.py | 4 + .../test-requirements.txt | 18 ++ .../tests/__init__.py | 0 .../tests/test_internal.py | 2 + pyproject.toml | 6 +- tox.ini | 11 + uv.lock | 23 ++ 16 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/LICENSE create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/README.rst create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py diff --git a/exporter/opentelemetry-exporter-otlp-json-http/LICENSE b/exporter/opentelemetry-exporter-otlp-json-http/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/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-http/README.rst b/exporter/opentelemetry-exporter-otlp-json-http/README.rst new file mode 100644 index 00000000000..11c8dbef554 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/README.rst @@ -0,0 +1,30 @@ +OpenTelemetry JSON File Exporter +================================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-exporter-otlp-json-file.svg + :target: https://pypi.org/project/opentelemetry-exporter-otlp-json-file/ + +This library exports telemetry as OTLP JSON to a file-like text stream, such +as a file or stdout. + +The exporter writes newline delimited OTLP JSON records for file-based +collection workflows. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-otlp-json-file + + +References +---------- + +* `OpenTelemetry Project `_ +* `OpenTelemetry Protocol File Exporter `_ +* `OTLP Specification `_ +* `OTLP JSON Encoding Specification `_ +* `JSON Lines `_ diff --git a/exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml b/exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml new file mode 100644 index 00000000000..c027482c217 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-exporter-otlp-json-http" +dynamic = ["version"] +description = "OpenTelemetry OTLP JSON 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-sdk ~= 1.44.0.dev", + "opentelemetry-proto-json == 0.65b0.dev", + "opentelemetry-exporter-otlp-common == 0.65b0.dev", + "opentelemetry-exporter-otlp-json-common == 0.65b0.dev", + "opentelemetry-exporter-http-transport[urllib3] == 0.65b0.dev", +] + +[project.entry-points.opentelemetry_traces_exporter] +otlp_json_http = "opentelemetry.exporter.otlp.json.http.trace_exporter:OTLPSpanExporter" + +[project.entry-points.opentelemetry_metrics_exporter] +otlp_json_http = "opentelemetry.exporter.otlp.json.http.metric_exporter:OTLPMetricExporter" + +[project.entry-points.opentelemetry_logs_exporter] +otlp_json_http = "opentelemetry.exporter.otlp.json.http._log_exporter:OTLPLogExporter" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/exporter/opentelemetry-exporter-otlp-json-http" +Repository = "https://github.com/open-telemetry/opentelemetry-python" + +[tool.hatch.version] +path = "src/opentelemetry/exporter/otlp/json/http/version/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py new file mode 100644 index 00000000000..5c41df76028 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py @@ -0,0 +1,14 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from opentelemetry.exporter.otlp.json.http.metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.exporter.otlp.json.http.trace_exporter import ( + OTLPSpanExporter, +) + +__all__ = [ + "OTLPMetricExporter", + "OTLPSpanExporter", +] diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py new file mode 100644 index 00000000000..e57cf4aba95 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py new file mode 100644 index 00000000000..0f99120274d --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py @@ -0,0 +1,28 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Sequence + +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__) +_logger.addFilter(DuplicateFilter()) + + +class OTLPLogExporter(LogRecordExporter): + def export( + self, batch: Sequence[ReadableLogRecord] + ) -> LogRecordExportResult: + return LogRecordExportResult.SUCCESS + + def shutdown(self) -> None: + pass + + def force_flush(self, timeout_millis: int = 10_000) -> bool: + return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py new file mode 100644 index 00000000000..564848cc2d4 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -0,0 +1,28 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import logging + +from opentelemetry.sdk.metrics.export import ( + MetricExporter, + MetricExportResult, + MetricsData, +) + +_logger = logging.getLogger(__name__) + + +class OTLPMetricExporter(MetricExporter): + def export( + self, + metrics_data: MetricsData, + timeout_millis: float = 10_000, + **kwargs, + ) -> MetricExportResult: + return MetricExportResult.SUCCESS + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + pass + + def force_flush(self, timeout_millis: float = 10_000) -> bool: + return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py new file mode 100644 index 00000000000..5778d1c6176 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py @@ -0,0 +1,21 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Sequence + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + +_logger = logging.getLogger(__name__) + + +class OTLPSpanExporter(SpanExporter): + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + return SpanExportResult.SUCCESS + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + pass + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py new file mode 100644 index 00000000000..357a2a12e6b --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/version/__init__.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.65b0.dev" diff --git a/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt new file mode 100644 index 00000000000..dee1f129bbe --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/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-http/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py new file mode 100644 index 00000000000..33ba6cc9de2 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py @@ -0,0 +1,2 @@ +def test_internal(): + assert True diff --git a/pyproject.toml b/pyproject.toml index a273dff32da..83c6fcfe34d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "opentelemetry-proto-json", "opentelemetry-test-utils", "opentelemetry-exporter-otlp-json-file", + "opentelemetry-exporter-otlp-json-http", "opentelemetry-exporter-http-transport", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-exporter-otlp-proto-http", @@ -34,12 +35,14 @@ 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-http-transport = { workspace = true } +opentelemetry-exporter-otlp-json-file = { workspace = true } +opentelemetry-exporter-otlp-json-http = { workspace = true } opentelemetry-exporter-otlp-proto-grpc = { workspace = true } opentelemetry-exporter-otlp-proto-http = { workspace = true } opentelemetry-exporter-otlp-proto-common = { workspace = true } opentelemetry-exporter-otlp-json-common = { workspace = true } +opentelemetry-exporter-otlp-common = { workspace = true } opentelemetry-exporter-zipkin-json = { workspace = true } opentelemetry-exporter-prometheus = {workspace = true } opentelemetry-propagator-jaeger = { workspace = true } @@ -124,6 +127,7 @@ include = [ "opentelemetry-sdk", "opentelemetry-proto-json", "exporter/opentelemetry-exporter-otlp-json-file", + "exporter/opentelemetry-exporter-otlp-json-http", "exporter/opentelemetry-exporter-http-transport", "exporter/opentelemetry-exporter-otlp-proto-grpc", "exporter/opentelemetry-exporter-otlp-proto-http", diff --git a/tox.ini b/tox.ini index 1acd90d9764..a2b4b052e59 100644 --- a/tox.ini +++ b/tox.ini @@ -74,6 +74,10 @@ envlist = pypy3-test-opentelemetry-exporter-otlp-json-file lint-opentelemetry-exporter-otlp-json-file + py3{10,11,12,13,14,14t}-test-opentelemetry-exporter-otlp-json-http + pypy3-test-opentelemetry-exporter-otlp-json-http + lint-opentelemetry-exporter-otlp-json-http + 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 @@ -165,6 +169,8 @@ deps = exporter-otlp-json-file: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt + exporter-otlp-json-http: -r {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http/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 @@ -272,6 +278,9 @@ commands = 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-json-http: pytest {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http/tests {posargs} + lint-opentelemetry-exporter-otlp-json-http: sh -c "cd exporter && pylint --rcfile ../.pylintrc {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http" + 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} @@ -435,7 +444,9 @@ 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-common -e {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file + -e {toxinidir}/exporter/opentelemetry-exporter-otlp-json-http -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 d51aa27ce55..0160a004660 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ members = [ "opentelemetry-exporter-otlp-common", "opentelemetry-exporter-otlp-json-common", "opentelemetry-exporter-otlp-json-file", + "opentelemetry-exporter-otlp-json-http", "opentelemetry-exporter-otlp-proto-common", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-exporter-otlp-proto-http", @@ -927,6 +928,26 @@ requires-dist = [ { name = "opentelemetry-sdk", editable = "opentelemetry-sdk" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-json-http" +source = { editable = "exporter/opentelemetry-exporter-otlp-json-http" } +dependencies = [ + { name = "opentelemetry-exporter-http-transport", extra = ["urllib3"] }, + { name = "opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-json-common" }, + { name = "opentelemetry-proto-json" }, + { name = "opentelemetry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-exporter-http-transport", extras = ["urllib3"], editable = "exporter/opentelemetry-exporter-http-transport" }, + { name = "opentelemetry-exporter-otlp-common", editable = "exporter/opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-json-common", editable = "exporter/opentelemetry-exporter-otlp-json-common" }, + { name = "opentelemetry-proto-json", editable = "opentelemetry-proto-json" }, + { name = "opentelemetry-sdk", editable = "opentelemetry-sdk" }, +] + [[package]] name = "opentelemetry-exporter-otlp-proto-common" source = { editable = "exporter/opentelemetry-exporter-otlp-proto-common" } @@ -1081,6 +1102,7 @@ dependencies = [ { name = "opentelemetry-exporter-http-transport" }, { name = "opentelemetry-exporter-otlp-json-common" }, { name = "opentelemetry-exporter-otlp-json-file" }, + { name = "opentelemetry-exporter-otlp-json-http" }, { name = "opentelemetry-exporter-otlp-proto-common" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -1111,6 +1133,7 @@ requires-dist = [ { name = "opentelemetry-exporter-http-transport", editable = "exporter/opentelemetry-exporter-http-transport" }, { 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-json-http", editable = "exporter/opentelemetry-exporter-otlp-json-http" }, { 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 e0b12fc88f655ec92c3f7ed8de74d8ed05834755 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 20:51:35 -0500 Subject: [PATCH 02/20] first phase of changes --- .../exporter/otlp/common/__init__.py | 4 + .../otlp/json/common/_internal/__init__.py | 104 ------------- .../tests/test_common_encoder.py | 141 ------------------ .../otlp/json/file/metric_exporter.py | 2 +- .../exporter/otlp/json/http/_internal.py | 78 ++++++++++ .../otlp/json/http/metric_exporter.py | 63 ++++++++ .../exporter/otlp/json/http/trace_exporter.py | 130 +++++++++++++++- .../test-requirements.txt | 5 +- 8 files changed, 277 insertions(+), 250 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py index e57cf4aba95..be3d705c562 100644 --- a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py @@ -1,2 +1,6 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 + +from opentelemetry.exporter.otlp.common._http import Compression + +__all__ = ["Compression"] diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py index a59ab85fe47..310c399cdcb 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/__init__.py @@ -7,7 +7,6 @@ import logging from collections.abc import Mapping, Sequence -from os import environ from typing import Any from opentelemetry.proto_json.common.v1.common import AnyValue as JSONAnyValue @@ -24,24 +23,6 @@ from opentelemetry.proto_json.resource.v1.resource import ( Resource as JSONResource, ) -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, -) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.util.types import _ExtendedAttributes @@ -121,88 +102,3 @@ def _encode_attributes( except Exception as error: _logger.exception("Failed to encode key %s: %s", key, error) return json_attributes - - -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-common/tests/test_common_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_common_encoder.py index 4eff30be6ff..4720ac56ba4 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_common_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_common_encoder.py @@ -6,7 +6,6 @@ import base64 import unittest from logging import ERROR -from unittest.mock import patch from opentelemetry.exporter.otlp.json.common._internal import ( _encode_attributes, @@ -16,8 +15,6 @@ _encode_span_id, _encode_trace_id, _encode_value, - _get_aggregation, - _get_temporality, ) from opentelemetry.proto_json.common.v1.common import AnyValue as JSONAnyValue from opentelemetry.proto_json.common.v1.common import ( @@ -33,23 +30,6 @@ from opentelemetry.proto_json.resource.v1.resource import ( Resource as JSONResource, ) -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, -) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import InstrumentationScope @@ -352,124 +332,3 @@ def test_encode_instrumentation_scope_none(self): result = _encode_instrumentation_scope(None) self.assertEqual(result, JSONInstrumentationScope()) self.assertEqual(result.to_dict(), {}) - - -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(_COMMON_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(_COMMON_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/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 e0183566ad3..0df0328d73f 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 @@ -5,7 +5,7 @@ import os from typing import IO, Any, overload -from opentelemetry.exporter.otlp.json.common._internal import ( +from opentelemetry.exporter.otlp.common._aggregation import ( _get_aggregation, _get_temporality, ) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py index e57cf4aba95..d41c10b091c 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -1,2 +1,80 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 + +import logging +import os +from collections.abc import Mapping + +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.json.http.version import __version__ +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.util.re import parse_env_headers + +_DEFAULT_ENDPOINT = "http://localhost:4318/" +_DEFAULT_TIMEOUT = 10 + +_logger = logging.getLogger(__name__) + + +def _resolve_endpoint(endpoint_env_var: str, default_path: str) -> str: + if endpoint := os.environ.get(endpoint_env_var): + return endpoint + + base_endpoint = os.environ.get( + OTEL_EXPORTER_OTLP_ENDPOINT, _DEFAULT_ENDPOINT + ) + + return f"{base_endpoint.removesuffix('/')}/{default_path}" + + +def _resolve_headers( + headers: Mapping[str, str] | None, + headers_env_var: str, +) -> dict[str, str]: + headers_ = { + "Content-Type": "application/json", + "User-Agent": "OTel-OTLP-JSON-Exporter-Python/" + __version__, + } + env_headers = parse_env_headers( + os.environ.get( + headers_env_var, os.environ.get(OTEL_EXPORTER_OTLP_HEADERS, "") + ), + liberal=True, + ) + headers_.update(env_headers) + if headers: + headers_.update(headers) + return headers_ + + +def _resolve_timeout( + timeout_env_var: str, +) -> float: + return float( + os.environ.get( + timeout_env_var, + os.environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, _DEFAULT_TIMEOUT), + ) + ) + + +def _resolve_compression(compression_env_var: str) -> Compression: + val = ( + os.environ.get( + compression_env_var, + os.environ.get(OTEL_EXPORTER_OTLP_COMPRESSION, "none"), + ) + .lower() + .strip() + ) + + try: + return Compression.from_str(val) + except ValueError: + _logger.warning("Unsupported compression type: %s", val) + return Compression.NONE diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py index 564848cc2d4..2b8fdc71298 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -2,17 +2,80 @@ # SPDX-License-Identifier: Apache-2.0 import logging +from collections.abc import Mapping +from typing import overload +from opentelemetry.exporter.http.transport import BaseHTTPTransport +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common._aggregation import ( + _get_aggregation, + _get_temporality, +) from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, MetricExporter, MetricExportResult, MetricsData, ) +from opentelemetry.sdk.metrics.view import Aggregation _logger = logging.getLogger(__name__) class OTLPMetricExporter(MetricExporter): + @overload + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + max_export_batch_size: int | None = None, + ) -> None: ... + + @overload + def __init__( + self, + endpoint: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + max_export_batch_size: int | None = None, + *, + _transport: BaseHTTPTransport, + ) -> None: ... + + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + preferred_temporality: dict[type, AggregationTemporality] + | None = None, + preferred_aggregation: dict[type, Aggregation] | None = None, + max_export_batch_size: int | None = None, + *, + _transport: BaseHTTPTransport | None = None, + ) -> None: + MetricExporter.__init__( + self, + preferred_temporality=_get_temporality(preferred_temporality), + preferred_aggregation=_get_aggregation(preferred_aggregation), + ) + def export( self, metrics_data: MetricsData, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py index 5778d1c6176..0dfbb516024 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py @@ -2,20 +2,144 @@ # SPDX-License-Identifier: Apache-2.0 import logging -from collections.abc import Sequence +import os +from collections.abc import Mapping, Sequence +from typing import overload +from opentelemetry.exporter.http.transport import BaseHTTPTransport +from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient +from opentelemetry.exporter.otlp.json.common._internal.trace_encoder import ( + encode_spans, +) +from opentelemetry.exporter.otlp.json.http._internal import ( + _resolve_compression, + _resolve_endpoint, + _resolve_headers, + _resolve_timeout, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_HEADERS, + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, +) from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +_DEFAULT_TRACES_EXPORT_PATH = "v1/traces" + _logger = logging.getLogger(__name__) class OTLPSpanExporter(SpanExporter): + @overload + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + ) -> None: ... + + @overload + def __init__( + self, + endpoint: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + *, + _transport: BaseHTTPTransport, + ) -> None: ... + + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + *, + _transport: BaseHTTPTransport | None = None, + ) -> None: + certificate_file = certificate_file or os.environ.get( + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + client_key_file = client_key_file or os.environ.get( + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), + ) + client_certificate_file = client_certificate_file or os.environ.get( + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), + ) + transport = ( + _transport + if _transport + else Urllib3HTTPTransport( + verify=certificate_file, + cert=(client_certificate_file, client_key_file) + if client_certificate_file and client_key_file + else client_certificate_file, + ) + ) + self._client = OTLPHTTPClient( + transport=transport, + endpoint=endpoint + or _resolve_endpoint( + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, _DEFAULT_TRACES_EXPORT_PATH + ), + kind="spans", + timeout=timeout + if timeout is not None + else _resolve_timeout(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT), + compression=compression + if compression is not None + else _resolve_compression(OTEL_EXPORTER_OTLP_TRACES_COMPRESSION), + headers=_resolve_headers( + headers, OTEL_EXPORTER_OTLP_TRACES_HEADERS + ), + logger=_logger, + ) + self._shutdown = False + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: - return SpanExportResult.SUCCESS + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring batch") + return SpanExportResult.FAILURE + try: + body = encode_spans(spans).to_json().encode() + # pylint: disable-next=broad-exception-caught + except Exception as error: + _logger.error("Failed to encode spans: %s", error) + return SpanExportResult.FAILURE + export_result = self._client.export(body) + return ( + SpanExportResult.SUCCESS + if export_result.success + else SpanExportResult.FAILURE + ) 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 + self._client.shutdown() def force_flush(self, timeout_millis: int = 30000) -> bool: return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt index dee1f129bbe..ebb8bf6ba8a 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt +++ b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt @@ -7,6 +7,7 @@ py-cpuinfo==9.0.0 pytest==7.4.4 tomli==2.0.1 typing_extensions==4.10.0 +urllib3==2.2.2 wrapt==1.16.0 zipp==3.19.2 -e opentelemetry-api @@ -14,5 +15,7 @@ zipp==3.19.2 -e opentelemetry-semantic-conventions -e tests/opentelemetry-test-utils -e opentelemetry-proto-json +-e exporter/opentelemetry-exporter-http-transport +-e exporter/opentelemetry-exporter-otlp-common -e exporter/opentelemetry-exporter-otlp-json-common --e exporter/opentelemetry-exporter-otlp-json-file +-e exporter/opentelemetry-exporter-otlp-json-http From 32ca13c15c2f71775d85f34fea8cd051271f02e8 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 21:37:16 -0500 Subject: [PATCH 03/20] add split metrics data helper --- .../_internal/metrics_encoder/__init__.py | 141 +++++- .../tests/test_metrics_split.py | 432 ++++++++++++++++++ .../exporter/otlp/json/http/_log_exporter.py | 128 +++++- .../otlp/json/http/metric_exporter.py | 92 +++- .../exporter/otlp/json/http/trace_exporter.py | 2 +- 5 files changed, 787 insertions(+), 8 deletions(-) create mode 100644 exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py index f7a572c7219..e3dc8dc5124 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py @@ -6,7 +6,8 @@ from __future__ import annotations import logging -from collections.abc import Collection +from collections.abc import Collection, Iterable +from dataclasses import replace from typing import TYPE_CHECKING from opentelemetry.exporter.otlp.json.common._internal import ( @@ -72,6 +73,8 @@ _logger = logging.getLogger(__name__) +_METRIC_DATA_FIELDS = ("gauge", "sum", "histogram", "exponential_histogram") + def encode_metrics(data: MetricsData) -> JSONExportMetricsServiceRequest: return JSONExportMetricsServiceRequest( @@ -81,6 +84,142 @@ def encode_metrics(data: MetricsData) -> JSONExportMetricsServiceRequest: ) +# pylint: disable-next=too-many-locals +def split_metrics_data( + metrics_data: JSONExportMetricsServiceRequest, + max_export_batch_size: int | None, +) -> Iterable[JSONExportMetricsServiceRequest]: + """Split an ExportMetricsServiceRequest into batches of at most + max_export_batch_size data points, preserving resource/scope hierarchy. + """ + if max_export_batch_size is None: + yield metrics_data + return + + batch_size: int = 0 + resource_metrics_batch: list[JSONResourceMetrics] = [] + scope_metrics_batch: list[JSONScopeMetrics] = [] + metrics_batch: list[JSONMetric] = [] + + for ( + resource_metrics, + scope_metrics, + metric, + field_name, + data_points, + ) in _iter_metric_data_points(metrics_data): + if ( + not resource_metrics_batch + or resource_metrics_batch[-1].resource + is not resource_metrics.resource + ): + scope_metrics_batch = [] + resource_metrics_batch.append( + replace(resource_metrics, scope_metrics=scope_metrics_batch) + ) + + if ( + not scope_metrics_batch + or scope_metrics_batch[-1].scope is not scope_metrics.scope + ): + metrics_batch = [] + scope_metrics_batch.append( + replace(scope_metrics, metrics=metrics_batch) + ) + + data_points_batch: list = [] + metrics_batch.append( + _build_metric_with_data_points( + metric, field_name, data_points_batch + ) + ) + + for data_point in data_points: + if batch_size >= max_export_batch_size: + yield JSONExportMetricsServiceRequest( + resource_metrics=resource_metrics_batch + ) + ( + resource_metrics_batch, + scope_metrics_batch, + metrics_batch, + data_points_batch, + ) = _build_empty_metric_batches( + resource_metrics, scope_metrics, metric, field_name + ) + batch_size = 0 + data_points_batch.append(data_point) + batch_size += 1 + + if batch_size > 0: + yield JSONExportMetricsServiceRequest( + resource_metrics=resource_metrics_batch + ) + + +def _get_metric_data_field_name(metric: JSONMetric) -> str | None: + return next( + (f for f in _METRIC_DATA_FIELDS if getattr(metric, f) is not None), + None, + ) + + +def _iter_metric_data_points( + metrics_data: JSONExportMetricsServiceRequest, +) -> Iterable[ + tuple[JSONResourceMetrics, JSONScopeMetrics, JSONMetric, str, list] +]: + for resource_metrics in metrics_data.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + field_name = _get_metric_data_field_name(metric) + if field_name is None: + _logger.warning( + "Tried to split and export an unsupported metric type. Skipping." + ) + continue + yield ( + resource_metrics, + scope_metrics, + metric, + field_name, + getattr(metric, field_name).data_points, + ) + + +def _build_metric_with_data_points( + metric: JSONMetric, + field_name: str, + data_points: list, +) -> JSONMetric: + new_data = replace(getattr(metric, field_name), data_points=data_points) + return replace(metric, **{field_name: new_data}) + + +def _build_empty_metric_batches( + resource_metrics: JSONResourceMetrics, + scope_metrics: JSONScopeMetrics, + metric: JSONMetric, + field_name: str, +) -> tuple[ + list[JSONResourceMetrics], list[JSONScopeMetrics], list[JSONMetric], list +]: + data_points_batch = [] + metrics_batch = [ + _build_metric_with_data_points(metric, field_name, data_points_batch) + ] + scope_metrics_batch = [replace(scope_metrics, metrics=metrics_batch)] + resource_metrics_batch = [ + replace(resource_metrics, scope_metrics=scope_metrics_batch) + ] + return ( + resource_metrics_batch, + scope_metrics_batch, + metrics_batch, + data_points_batch, + ) + + def _encode_resource_metrics( rm: ResourceMetrics, ) -> JSONResourceMetrics: diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py new file mode 100644 index 00000000000..5611c58f49a --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py @@ -0,0 +1,432 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access,unsubscriptable-object + +import unittest +from logging import WARNING + +from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( + _METRIC_DATA_FIELDS, + _get_metric_data_field_name, + split_metrics_data, +) +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.proto_json.collector.metrics.v1.metrics_service import ( + ExportMetricsServiceRequest as JSONExportMetricsServiceRequest, +) +from opentelemetry.proto_json.metrics.v1.metrics import ( + NumberDataPoint as JSONNumberDataPoint, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + Buckets, + ExponentialHistogram, + ExponentialHistogramDataPoint, + Gauge, + Histogram, + HistogramDataPoint, + Metric, + MetricsData, + NumberDataPoint, + ResourceMetrics, + ScopeMetrics, + Sum, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + +from . import ( + START_TIME, + TIME, + make_exponential_histogram, + make_gauge, + make_histogram, + make_metrics_data, + make_sum, +) + + +def _get_first_metric(result: JSONExportMetricsServiceRequest) -> Metric: + return result.resource_metrics[0].scope_metrics[0].metrics[0] + + +def _number_dp(value: int) -> NumberDataPoint: + return NumberDataPoint( + attributes={"a": 1}, + start_time_unix_nano=START_TIME, + time_unix_nano=TIME, + value=value, + ) + + +def _histogram_dp(count: int) -> HistogramDataPoint: + return HistogramDataPoint( + attributes={"a": 1}, + start_time_unix_nano=START_TIME, + time_unix_nano=TIME, + count=count, + sum=count, + bucket_counts=[count], + explicit_bounds=[], + min=0, + max=count, + exemplars=[], + ) + + +def _exponential_histogram_dp(count: int) -> ExponentialHistogramDataPoint: + return ExponentialHistogramDataPoint( + attributes={"a": 1}, + start_time_unix_nano=START_TIME, + time_unix_nano=TIME, + count=count, + sum=float(count), + scale=1, + zero_count=0, + positive=Buckets(offset=0, bucket_counts=[count]), + negative=Buckets(offset=0, bucket_counts=[]), + flags=0, + min=0.0, + max=float(count), + exemplars=[], + ) + + +def _sum_metric(name: str, values: list[int]) -> Metric: + return Metric( + name=name, + description="desc", + unit="s", + data=Sum( + data_points=[_number_dp(v) for v in values], + aggregation_temporality=AggregationTemporality.CUMULATIVE, + is_monotonic=True, + ), + ) + + +def _metric_of_type(field_name: str, count: int) -> Metric: + values = list(range(1, count + 1)) + match field_name: + case "gauge": + data = Gauge(data_points=[_number_dp(v) for v in values]) + case "sum": + data = Sum( + data_points=[_number_dp(v) for v in values], + aggregation_temporality=AggregationTemporality.CUMULATIVE, + is_monotonic=True, + ) + case "histogram": + data = Histogram( + data_points=[_histogram_dp(v) for v in values], + aggregation_temporality=AggregationTemporality.DELTA, + ) + case _: # exponential_histogram + data = ExponentialHistogram( + data_points=[_exponential_histogram_dp(v) for v in values], + aggregation_temporality=AggregationTemporality.CUMULATIVE, + ) + return Metric(name=field_name, description="desc", unit="u", data=data) + + +def _scope_metrics(name: str, metrics: list[Metric]) -> ScopeMetrics: + return ScopeMetrics( + scope=InstrumentationScope( + name=name, version="1.0", schema_url="scope_url" + ), + metrics=metrics, + schema_url="scope_url", + ) + + +def _resource_metrics( + index: int, scope_metrics_list: list[ScopeMetrics] +) -> ResourceMetrics: + return ResourceMetrics( + resource=Resource( + attributes={"r": index}, schema_url=f"res_url_{index}" + ), + scope_metrics=scope_metrics_list, + schema_url=f"res_url_{index}", + ) + + +def _data_point_field(data_point: JSONNumberDataPoint) -> int | float: + return ( + data_point.as_int + if data_point.as_int is not None + else data_point.as_double + ) + + +def _all_values(request: JSONExportMetricsServiceRequest) -> list[int | float]: + """Flatten every data point value in a request, in hierarchy order.""" + values = [] + for resource_metrics in request.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + field_name = _get_metric_data_field_name(metric) + for dp in getattr(metric, field_name).data_points: + values.append(_data_point_field(dp)) + return values + + +def _count_data_points(request: JSONExportMetricsServiceRequest) -> int: + return sum( + len(getattr(metric, _get_metric_data_field_name(metric)).data_points) + for resource_metrics in request.resource_metrics + for scope_metrics in resource_metrics.scope_metrics + for metric in scope_metrics.metrics + ) + + +class TestSplitMetricsData(unittest.TestCase): + def test_get_metric_data_field_name(self): + cases = { + "gauge": make_gauge(), + "sum": make_sum(), + "histogram": make_histogram(), + "exponential_histogram": make_exponential_histogram(), + } + for expected, metric in cases.items(): + with self.subTest(field_name=expected): + encoded = _get_first_metric( + encode_metrics(make_metrics_data([metric])) + ) + self.assertEqual( + _get_metric_data_field_name(encoded), expected + ) + + def test_get_metric_data_field_name_unsupported(self): + with self.assertLogs(level=WARNING): + encoded = _get_first_metric( + encode_metrics( + make_metrics_data( + [ + Metric( + name="x", description="d", unit="u", data=None + ) + ] + ) + ) + ) + self.assertIsNone(_get_metric_data_field_name(encoded)) + + def test_none_batch_size_yields_original_unchanged(self): + request = encode_metrics(make_metrics_data([make_sum(value=1)])) + batches = list(split_metrics_data(request, None)) + self.assertEqual(len(batches), 1) + self.assertIs(batches[0], request) + + def test_split_single_metric_even(self): + request = encode_metrics( + make_metrics_data([_sum_metric("s", [0, 1, 2, 3])]) + ) + batches = list(split_metrics_data(request, 2)) + self.assertEqual(len(batches), 2) + self.assertEqual([_all_values(b) for b in batches], [[0, 1], [2, 3]]) + + def test_split_single_metric_uneven(self): + request = encode_metrics( + make_metrics_data([_sum_metric("s", [0, 1, 2, 3, 4])]) + ) + batches = list(split_metrics_data(request, 2)) + self.assertEqual( + [_all_values(b) for b in batches], [[0, 1], [2, 3], [4]] + ) + + def test_split_batch_size_one(self): + request = encode_metrics( + make_metrics_data([_sum_metric("s", [7, 8, 9])]) + ) + batches = list(split_metrics_data(request, 1)) + self.assertEqual([_all_values(b) for b in batches], [[7], [8], [9]]) + + def test_split_batch_larger_than_total(self): + request = encode_metrics( + make_metrics_data([_sum_metric("s", [0, 1, 2])]) + ) + batches = list(split_metrics_data(request, 100)) + self.assertEqual(len(batches), 1) + self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + + def test_split_preserves_metric_metadata(self): + request = encode_metrics( + make_metrics_data([_sum_metric("s", [0, 1, 2, 3, 4])]) + ) + for batch in split_metrics_data(request, 2): + metric = _get_first_metric(batch) + self.assertEqual(metric.name, "s") + self.assertEqual(metric.unit, "s") + self.assertEqual(metric.description, "desc") + self.assertIsNotNone(metric.sum) + self.assertEqual( + metric.sum.aggregation_temporality, + AggregationTemporality.CUMULATIVE, + ) + self.assertTrue(metric.sum.is_monotonic) + + def test_split_across_metrics_in_scope(self): + request = encode_metrics( + make_metrics_data( + [_sum_metric("a", [0, 1]), _sum_metric("b", [2, 3])] + ) + ) + batches = list(split_metrics_data(request, 3)) + self.assertEqual(len(batches), 2) + + first_metrics = batches[0].resource_metrics[0].scope_metrics[0].metrics + self.assertEqual([m.name for m in first_metrics], ["a", "b"]) + self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + + second_metrics = ( + batches[1].resource_metrics[0].scope_metrics[0].metrics + ) + self.assertEqual([m.name for m in second_metrics], ["b"]) + self.assertEqual(_all_values(batches[1]), [3]) + + def test_split_across_scopes(self): + request = encode_metrics( + MetricsData( + resource_metrics=[ + _resource_metrics( + 0, + [ + _scope_metrics("s0", [_sum_metric("a", [0, 1])]), + _scope_metrics("s1", [_sum_metric("b", [2, 3])]), + ], + ) + ] + ) + ) + batches = list(split_metrics_data(request, 3)) + self.assertEqual(len(batches), 2) + + self.assertEqual(len(batches[0].resource_metrics[0].scope_metrics), 2) + self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + + self.assertEqual(len(batches[1].resource_metrics[0].scope_metrics), 1) + self.assertEqual( + batches[1].resource_metrics[0].scope_metrics[0].scope.name, "s1" + ) + self.assertEqual(_all_values(batches[1]), [3]) + + def test_split_across_resources(self): + request = encode_metrics( + MetricsData( + resource_metrics=[ + _resource_metrics( + 0, [_scope_metrics("s0", [_sum_metric("a", [0, 1])])] + ), + _resource_metrics( + 1, [_scope_metrics("s1", [_sum_metric("b", [2, 3])])] + ), + ] + ) + ) + batches = list(split_metrics_data(request, 3)) + self.assertEqual(len(batches), 2) + self.assertEqual(len(batches[0].resource_metrics), 2) + self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + self.assertEqual(len(batches[1].resource_metrics), 1) + self.assertEqual(_all_values(batches[1]), [3]) + + def test_split_all_metric_types(self): + for field_name in _METRIC_DATA_FIELDS: + with self.subTest(field_name=field_name): + request = encode_metrics( + make_metrics_data([_metric_of_type(field_name, 3)]) + ) + batches = list(split_metrics_data(request, 2)) + self.assertEqual(len(batches), 2) + self.assertEqual(_count_data_points(batches[0]), 2) + self.assertEqual(_count_data_points(batches[1]), 1) + for batch in batches: + metric = _get_first_metric(batch) + self.assertEqual( + _get_metric_data_field_name(metric), field_name + ) + + def test_split_preserves_all_data_points(self): + request = encode_metrics( + MetricsData( + resource_metrics=[ + _resource_metrics( + 0, + [ + _scope_metrics( + "s0", + [ + _sum_metric("a", [0, 1, 2]), + _sum_metric("b", [3, 4]), + ], + ), + _scope_metrics( + "s1", [_sum_metric("c", [5, 6, 7])] + ), + ], + ), + _resource_metrics( + 1, [_scope_metrics("s2", [_sum_metric("d", [8, 9])])] + ), + ] + ) + ) + expected = _all_values(request) + self.assertEqual(expected, list(range(10))) + for batch_size in (1, 2, 3, 4, 7, 1000): + with self.subTest(batch_size=batch_size): + batches = list(split_metrics_data(request, batch_size)) + flattened = [v for b in batches for v in _all_values(b)] + self.assertEqual(flattened, expected) + self.assertEqual( + sum(_count_data_points(b) for b in batches), len(expected) + ) + for batch in batches: + self.assertLessEqual(_count_data_points(batch), batch_size) + + def test_split_skips_unsupported_metric(self): + request = encode_metrics( + make_metrics_data( + [ + _sum_metric("good", [0, 1]), + Metric(name="bad", description="d", unit="u", data=None), + ] + ) + ) + with self.assertLogs(level=WARNING) as log_ctx: + batches = list(split_metrics_data(request, 5)) + self.assertTrue( + any("unsupported metric type" in m for m in log_ctx.output) + ) + self.assertEqual(len(batches), 1) + self.assertEqual(_all_values(batches[0]), [0, 1]) + names = [ + m.name + for b in batches + for rm in b.resource_metrics + for sm in rm.scope_metrics + for m in sm.metrics + ] + self.assertEqual(names, ["good"]) + + def test_split_empty_request(self): + request = JSONExportMetricsServiceRequest(resource_metrics=[]) + self.assertEqual(list(split_metrics_data(request, 5)), []) + + def test_split_metric_with_no_data_points(self): + metric = Metric( + name="empty", + description="d", + unit="u", + data=Sum( + data_points=[], + aggregation_temporality=AggregationTemporality.CUMULATIVE, + is_monotonic=True, + ), + ) + request = encode_metrics(make_metrics_data([metric])) + self.assertEqual(list(split_metrics_data(request, 5)), []) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py index 0f99120274d..c6784f93dbf 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py @@ -2,27 +2,149 @@ # SPDX-License-Identifier: Apache-2.0 import logging -from collections.abc import Sequence +import os +from collections.abc import Mapping, Sequence +from typing import overload +from opentelemetry.exporter.http.transport._base import BaseHTTPTransport +from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient +from opentelemetry.exporter.otlp.json.common._internal._log_encoder import ( + encode_logs, +) +from opentelemetry.exporter.otlp.json.http._internal import ( + _resolve_compression, + _resolve_endpoint, + _resolve_headers, + _resolve_timeout, +) from opentelemetry.sdk._logs import ReadableLogRecord from opentelemetry.sdk._logs.export import ( LogRecordExporter, LogRecordExportResult, ) from opentelemetry.sdk._shared_internal import DuplicateFilter +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_LOGS_COMPRESSION, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + OTEL_EXPORTER_OTLP_LOGS_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, +) + +_DEFAULT_LOGS_EXPORT_PATH = "v1/logs" _logger = logging.getLogger(__name__) _logger.addFilter(DuplicateFilter()) class OTLPLogExporter(LogRecordExporter): + @overload + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + ) -> None: ... + + @overload + def __init__( + self, + endpoint: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + *, + _transport: BaseHTTPTransport, + ) -> None: ... + + def __init__( + self, + endpoint: str | None = None, + certificate_file: str | None = None, + client_key_file: str | None = None, + client_certificate_file: str | None = None, + headers: Mapping[str, str] | None = None, + timeout: float | None = None, + compression: Compression | None = None, + *, + _transport: BaseHTTPTransport | None = None, + ) -> None: + certificate_file = certificate_file or os.environ.get( + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + client_key_file = client_key_file or os.environ.get( + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), + ) + client_certificate_file = client_certificate_file or os.environ.get( + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), + ) + transport = ( + _transport + if _transport + else Urllib3HTTPTransport( + verify=certificate_file, + cert=(client_certificate_file, client_key_file) + if client_certificate_file and client_key_file + else client_certificate_file, + ) + ) + self._client = OTLPHTTPClient( + transport=transport, + endpoint=endpoint + or _resolve_endpoint( + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, _DEFAULT_LOGS_EXPORT_PATH + ), + kind="logs", + timeout=timeout + if timeout is not None + else _resolve_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT), + compression=compression + if compression is not None + else _resolve_compression(OTEL_EXPORTER_OTLP_LOGS_COMPRESSION), + headers=_resolve_headers(headers, OTEL_EXPORTER_OTLP_LOGS_HEADERS), + logger=_logger, + ) + self._shutdown = False + def export( self, batch: Sequence[ReadableLogRecord] ) -> LogRecordExportResult: - return LogRecordExportResult.SUCCESS + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring batch") + return LogRecordExportResult.FAILURE + try: + body = encode_logs(batch).to_json().encode() + # pylint: disable-next=broad-exception-caught + except Exception as error: + _logger.error("Failed to encode logs: %s", error) + return LogRecordExportResult.FAILURE + export_result = self._client.export(body) + return ( + LogRecordExportResult.SUCCESS + if export_result.success + else LogRecordExportResult.FAILURE + ) def shutdown(self) -> None: - pass + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring call") + return + self._shutdown = True + self._client.shutdown() def force_flush(self, timeout_millis: int = 10_000) -> bool: return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py index 2b8fdc71298..e691320c8dc 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -2,15 +2,39 @@ # SPDX-License-Identifier: Apache-2.0 import logging +import os from collections.abc import Mapping from typing import overload -from opentelemetry.exporter.http.transport import BaseHTTPTransport +from opentelemetry.exporter.http.transport._base import BaseHTTPTransport +from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.common._aggregation import ( _get_aggregation, _get_temporality, ) +from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient +from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.exporter.otlp.json.http._internal import ( + _resolve_compression, + _resolve_endpoint, + _resolve_headers, + _resolve_timeout, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_METRICS_COMPRESSION, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + OTEL_EXPORTER_OTLP_METRICS_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_TIMEOUT, +) from opentelemetry.sdk.metrics.export import ( AggregationTemporality, MetricExporter, @@ -19,6 +43,8 @@ ) from opentelemetry.sdk.metrics.view import Aggregation +_DEFAULT_METRICS_EXPORT_PATH = "v1/metrics" + _logger = logging.getLogger(__name__) @@ -75,6 +101,48 @@ def __init__( preferred_temporality=_get_temporality(preferred_temporality), preferred_aggregation=_get_aggregation(preferred_aggregation), ) + certificate_file = certificate_file or os.environ.get( + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + client_key_file = client_key_file or os.environ.get( + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), + ) + client_certificate_file = client_certificate_file or os.environ.get( + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), + ) + transport = ( + _transport + if _transport + else Urllib3HTTPTransport( + verify=certificate_file, + cert=(client_certificate_file, client_key_file) + if client_certificate_file and client_key_file + else client_certificate_file, + ) + ) + self._client = OTLPHTTPClient( + transport=transport, + endpoint=endpoint + or _resolve_endpoint( + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + _DEFAULT_METRICS_EXPORT_PATH, + ), + kind="metrics", + timeout=timeout + if timeout is not None + else _resolve_timeout(OTEL_EXPORTER_OTLP_METRICS_TIMEOUT), + compression=compression + if compression is not None + else _resolve_compression(OTEL_EXPORTER_OTLP_METRICS_COMPRESSION), + headers=_resolve_headers( + headers, OTEL_EXPORTER_OTLP_METRICS_HEADERS + ), + logger=_logger, + ) + self._shutdown = False def export( self, @@ -82,10 +150,28 @@ def export( timeout_millis: float = 10_000, **kwargs, ) -> MetricExportResult: - return MetricExportResult.SUCCESS + if self._shutdown: + _logger.warning("Exporter already shutdown, ignoring batch") + return MetricExportResult.FAILURE + try: + body = encode_metrics(metrics_data).to_json().encode() + # pylint: disable-next=broad-exception-caught + except Exception as error: + _logger.error("Failed to encode metrics: %s", error) + return MetricExportResult.FAILURE + export_result = self._client.export(body) + return ( + MetricExportResult.SUCCESS + if export_result.success + else MetricExportResult.FAILURE + ) 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 + self._client.shutdown() def force_flush(self, timeout_millis: float = 10_000) -> bool: return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py index 0dfbb516024..895d3d2b425 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py @@ -6,7 +6,7 @@ from collections.abc import Mapping, Sequence from typing import overload -from opentelemetry.exporter.http.transport import BaseHTTPTransport +from opentelemetry.exporter.http.transport._base import BaseHTTPTransport from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient From 95dca87981f5b85821f4b00f7bb56e643dfcc2b0 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 22:20:12 -0500 Subject: [PATCH 04/20] add unit tests for helper functions --- .../exporter/otlp/json/http/_log_exporter.py | 1 + .../otlp/json/http/metric_exporter.py | 17 +- .../tests/test_internal.py | 218 +++++++++++++++++- 3 files changed, 227 insertions(+), 9 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py index c6784f93dbf..9a3e783ad4d 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py @@ -146,5 +146,6 @@ def shutdown(self) -> None: self._shutdown = True self._client.shutdown() + # pylint: disable-next=no-self-use def force_flush(self, timeout_millis: int = 10_000) -> bool: return True diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py index e691320c8dc..8c4106c7328 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -16,6 +16,7 @@ from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( encode_metrics, + split_metrics_data, ) from opentelemetry.exporter.otlp.json.http._internal import ( _resolve_compression, @@ -142,6 +143,7 @@ def __init__( ), logger=_logger, ) + self._max_export_batch_size = max_export_batch_size self._shutdown = False def export( @@ -154,17 +156,18 @@ def export( _logger.warning("Exporter already shutdown, ignoring batch") return MetricExportResult.FAILURE try: - body = encode_metrics(metrics_data).to_json().encode() + export_request = encode_metrics(metrics_data) # pylint: disable-next=broad-exception-caught except Exception as error: _logger.error("Failed to encode metrics: %s", error) return MetricExportResult.FAILURE - export_result = self._client.export(body) - return ( - MetricExportResult.SUCCESS - if export_result.success - else MetricExportResult.FAILURE - ) + for request in split_metrics_data( + export_request, self._max_export_batch_size + ): + export_result = self._client.export(request.to_json().encode()) + if not export_result.success: + return MetricExportResult.FAILURE + return MetricExportResult.SUCCESS def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: if self._shutdown: diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py index 33ba6cc9de2..570a5b605bd 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py @@ -1,2 +1,216 @@ -def test_internal(): - assert True +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access + +import os +import unittest +from logging import WARNING +from unittest.mock import patch + +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.json.http._internal import ( + _DEFAULT_TIMEOUT, + _resolve_compression, + _resolve_endpoint, + _resolve_headers, + _resolve_timeout, +) +from opentelemetry.exporter.otlp.json.http.version import __version__ +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_HEADERS, + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, +) + +_USER_AGENT = "OTel-OTLP-JSON-Exporter-Python/" + __version__ +_BASE_HEADERS = {"Content-Type": "application/json", "User-Agent": _USER_AGENT} + + +class TestResolveInternal(unittest.TestCase): + def test_resolve_endpoint(self): + cases = [ + # per-signal wins over base and is returned verbatim (no path added) + ( + "per_signal_verbatim", + { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://per-signal:4318/v1/traces", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://base:4318", + }, + "v1/traces", + "http://per-signal:4318/v1/traces", + ), + ( + "base_no_trailing_slash", + {OTEL_EXPORTER_OTLP_ENDPOINT: "http://base:4318"}, + "v1/traces", + "http://base:4318/v1/traces", + ), + ( + "base_trailing_slash_normalized", + {OTEL_EXPORTER_OTLP_ENDPOINT: "http://base:4318/"}, + "v1/metrics", + "http://base:4318/v1/metrics", + ), + ( + "empty_per_signal_falls_back", + { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://base:4318", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "", + }, + "v1/traces", + "http://base:4318/v1/traces", + ), + ( + "default_traces", + {}, + "v1/traces", + "http://localhost:4318/v1/traces", + ), + ( + "default_logs", + {}, + "v1/logs", + "http://localhost:4318/v1/logs", + ), + ] + for label, env, default_path, expected in cases: + with self.subTest(label), patch.dict(os.environ, env, clear=True): + self.assertEqual( + _resolve_endpoint( + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, default_path + ), + expected, + ) + + def test_resolve_headers(self): + cases = [ + ("defaults_only", {}, None, _BASE_HEADERS), + ( + "general_env_merged", + {OTEL_EXPORTER_OTLP_HEADERS: "k1=v1,k2=v2"}, + None, + {**_BASE_HEADERS, "k1": "v1", "k2": "v2"}, + ), + # per-signal var is used instead of (not merged with) the general one + ( + "per_signal_overrides_general", + { + OTEL_EXPORTER_OTLP_HEADERS: "api-key=general,shared=g", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=per-signal", + }, + None, + {**_BASE_HEADERS, "api-key": "per-signal"}, + ), + # explicit arg wins over env and base, by exact key + ( + "explicit_arg_overrides", + {OTEL_EXPORTER_OTLP_HEADERS: "api-key=from-env"}, + {"api-key": "explicit", "Content-Type": "text/plain"}, + { + "Content-Type": "text/plain", + "User-Agent": _USER_AGENT, + "api-key": "explicit", + }, + ), + ] + for label, env, headers_arg, expected in cases: + with self.subTest(label), patch.dict(os.environ, env, clear=True): + self.assertEqual( + _resolve_headers( + headers_arg, OTEL_EXPORTER_OTLP_TRACES_HEADERS + ), + expected, + ) + + def test_resolve_timeout(self): + cases = [ + ( + "per_signal_wins", + { + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "5", + OTEL_EXPORTER_OTLP_TIMEOUT: "7", + }, + 5.0, + ), + ( + "falls_back_to_general", + {OTEL_EXPORTER_OTLP_TIMEOUT: "7"}, + 7.0, + ), + ( + "fractional", + {OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "2.5"}, + 2.5, + ), + ("default", {}, float(_DEFAULT_TIMEOUT)), + ] + for label, env, expected in cases: + with self.subTest(label), patch.dict(os.environ, env, clear=True): + result = _resolve_timeout(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT) + self.assertEqual(result, expected) + self.assertIsInstance(result, float) + + def test_resolve_compression(self): + cases = [ + ("gzip", {"COMP": "gzip"}, "COMP", Compression.GZIP, False), + ( + "deflate", + {"COMP": "deflate"}, + "COMP", + Compression.DEFLATE, + False, + ), + ("none", {"COMP": "none"}, "COMP", Compression.NONE, False), + ( + "case_and_whitespace", + {"COMP": " GzIp "}, + "COMP", + Compression.GZIP, + False, + ), + ( + "falls_back_to_general", + {OTEL_EXPORTER_OTLP_COMPRESSION: "deflate"}, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + Compression.DEFLATE, + False, + ), + ( + "per_signal_wins", + { + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "gzip", + OTEL_EXPORTER_OTLP_COMPRESSION: "deflate", + }, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + Compression.GZIP, + False, + ), + ( + "default", + {}, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + Compression.NONE, + False, + ), + ( + "invalid_warns", + {OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "bogus"}, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + Compression.NONE, + True, + ), + ] + for label, env, env_var, expected, warns in cases: + with self.subTest(label), patch.dict(os.environ, env, clear=True): + if warns: + with self.assertLogs(level=WARNING): + result = _resolve_compression(env_var) + else: + result = _resolve_compression(env_var) + self.assertEqual(result, expected) From 173d3b07766c7462ae0b2e52c8c8445e086465ff Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 22:28:00 -0500 Subject: [PATCH 05/20] regenerate workflows --- .changelog/5374.added | 1 + .github/workflows/lint.yml | 19 ++ .github/workflows/test.yml | 280 ++++++++++++++++++ .../pyproject.toml | 1 + .../test-requirements.txt | 1 + uv.lock | 2 + 6 files changed, 304 insertions(+) create mode 100644 .changelog/5374.added diff --git a/.changelog/5374.added b/.changelog/5374.added new file mode 100644 index 00000000000..a6204b8ab76 --- /dev/null +++ b/.changelog/5374.added @@ -0,0 +1 @@ +`opentelemetry-exporter-otlp-json-http`: add OTLP JSON HTTP exporter package diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f60b638b73b..049e698f816 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -328,6 +328,25 @@ jobs: - name: Run tests run: tox -e lint-opentelemetry-exporter-otlp-json-file + lint-opentelemetry-exporter-otlp-json-http: + name: opentelemetry-exporter-otlp-json-http + 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-http + 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 3953b1f1ac6..367dd622dd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2459,6 +2459,139 @@ jobs: - name: Run tests run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-file -- -ra + py310-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py311-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py312-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py313-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py314-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py314t-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + pypy3-test-opentelemetry-exporter-otlp-json-http_ubuntu-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -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 @@ -6411,6 +6544,153 @@ jobs: - name: Run tests run: tox -e pypy3-test-opentelemetry-exporter-otlp-json-file -- -ra + py310-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py311-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py312-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py313-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py314-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + py314t-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -ra + + pypy3-test-opentelemetry-exporter-otlp-json-http_windows-latest: + name: opentelemetry-exporter-otlp-json-http 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-http -- -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/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml index cc7b3cb00d3..783ae30d719 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml +++ b/exporter/opentelemetry-exporter-otlp-json-file/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "opentelemetry-exporter-otlp-common == 0.65b0.dev", "opentelemetry-exporter-otlp-json-common == 0.65b0.dev", "opentelemetry-sdk ~= 1.44.0.dev", ] diff --git a/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt index dee1f129bbe..83aff317863 100644 --- a/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt +++ b/exporter/opentelemetry-exporter-otlp-json-file/test-requirements.txt @@ -14,5 +14,6 @@ zipp==3.19.2 -e opentelemetry-semantic-conventions -e tests/opentelemetry-test-utils -e opentelemetry-proto-json +-e exporter/opentelemetry-exporter-otlp-common -e exporter/opentelemetry-exporter-otlp-json-common -e exporter/opentelemetry-exporter-otlp-json-file diff --git a/uv.lock b/uv.lock index 0160a004660..98d7c0b1f1f 100644 --- a/uv.lock +++ b/uv.lock @@ -918,12 +918,14 @@ requires-dist = [ name = "opentelemetry-exporter-otlp-json-file" source = { editable = "exporter/opentelemetry-exporter-otlp-json-file" } dependencies = [ + { name = "opentelemetry-exporter-otlp-common" }, { name = "opentelemetry-exporter-otlp-json-common" }, { name = "opentelemetry-sdk" }, ] [package.metadata] requires-dist = [ + { name = "opentelemetry-exporter-otlp-common", editable = "exporter/opentelemetry-exporter-otlp-common" }, { name = "opentelemetry-exporter-otlp-json-common", editable = "exporter/opentelemetry-exporter-otlp-json-common" }, { name = "opentelemetry-sdk", editable = "opentelemetry-sdk" }, ] From a34b6286856f805fd270a481fe0dfc9e0eaa7251 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 22:38:00 -0500 Subject: [PATCH 06/20] fix type errors --- .../json/common/_internal/metrics_encoder/__init__.py | 10 +++++----- .../exporter/otlp/json/http/_log_exporter.py | 7 +++++-- .../exporter/otlp/json/http/metric_exporter.py | 7 +++++-- .../exporter/otlp/json/http/trace_exporter.py | 7 +++++-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py index e3dc8dc5124..691b40e05a7 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py @@ -115,7 +115,7 @@ def split_metrics_data( ): scope_metrics_batch = [] resource_metrics_batch.append( - replace(resource_metrics, scope_metrics=scope_metrics_batch) + replace(resource_metrics, scope_metrics=scope_metrics_batch) # type: ignore[reportArgumentType] ) if ( @@ -124,7 +124,7 @@ def split_metrics_data( ): metrics_batch = [] scope_metrics_batch.append( - replace(scope_metrics, metrics=metrics_batch) + replace(scope_metrics, metrics=metrics_batch) # type: ignore[reportArgumentType] ) data_points_batch: list = [] @@ -193,7 +193,7 @@ def _build_metric_with_data_points( data_points: list, ) -> JSONMetric: new_data = replace(getattr(metric, field_name), data_points=data_points) - return replace(metric, **{field_name: new_data}) + return replace(metric, **{field_name: new_data}) # type: ignore[reportArgumentType] def _build_empty_metric_batches( @@ -208,9 +208,9 @@ def _build_empty_metric_batches( metrics_batch = [ _build_metric_with_data_points(metric, field_name, data_points_batch) ] - scope_metrics_batch = [replace(scope_metrics, metrics=metrics_batch)] + scope_metrics_batch = [replace(scope_metrics, metrics=metrics_batch)] # type: ignore[reportArgumentType] resource_metrics_batch = [ - replace(resource_metrics, scope_metrics=scope_metrics_batch) + replace(resource_metrics, scope_metrics=scope_metrics_batch) # type: ignore[reportArgumentType] ] return ( resource_metrics_batch, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py index 9a3e783ad4d..da3e6f605c8 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py @@ -61,6 +61,9 @@ def __init__( def __init__( self, endpoint: str | None = None, + certificate_file: None = None, + client_key_file: None = None, + client_certificate_file: None = None, headers: Mapping[str, str] | None = None, timeout: float | None = None, compression: Compression | None = None, @@ -80,7 +83,7 @@ def __init__( *, _transport: BaseHTTPTransport | None = None, ) -> None: - certificate_file = certificate_file or os.environ.get( + verify: bool | str = certificate_file or os.environ.get( OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), ) @@ -96,7 +99,7 @@ def __init__( _transport if _transport else Urllib3HTTPTransport( - verify=certificate_file, + verify=verify, cert=(client_certificate_file, client_key_file) if client_certificate_file and client_key_file else client_certificate_file, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py index 8c4106c7328..73a42bbbe63 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -70,6 +70,9 @@ def __init__( def __init__( self, endpoint: str | None = None, + certificate_file: None = None, + client_key_file: None = None, + client_certificate_file: None = None, headers: Mapping[str, str] | None = None, timeout: float | None = None, compression: Compression | None = None, @@ -102,7 +105,7 @@ def __init__( preferred_temporality=_get_temporality(preferred_temporality), preferred_aggregation=_get_aggregation(preferred_aggregation), ) - certificate_file = certificate_file or os.environ.get( + verify: bool | str = certificate_file or os.environ.get( OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), ) @@ -118,7 +121,7 @@ def __init__( _transport if _transport else Urllib3HTTPTransport( - verify=certificate_file, + verify=verify, cert=(client_certificate_file, client_key_file) if client_certificate_file and client_key_file else client_certificate_file, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py index 895d3d2b425..f43e4171838 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py @@ -56,6 +56,9 @@ def __init__( def __init__( self, endpoint: str | None = None, + certificate_file: None = None, + client_key_file: None = None, + client_certificate_file: None = None, headers: Mapping[str, str] | None = None, timeout: float | None = None, compression: Compression | None = None, @@ -75,7 +78,7 @@ def __init__( *, _transport: BaseHTTPTransport | None = None, ) -> None: - certificate_file = certificate_file or os.environ.get( + verify: bool | str = certificate_file or os.environ.get( OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), ) @@ -91,7 +94,7 @@ def __init__( _transport if _transport else Urllib3HTTPTransport( - verify=certificate_file, + verify=verify, cert=(client_certificate_file, client_key_file) if client_certificate_file and client_key_file else client_certificate_file, From d4fd1541080cbecc7f20c0ab1ae6152609eb97f6 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 30 Jun 2026 22:58:57 -0500 Subject: [PATCH 07/20] update eachdist.ini --- eachdist.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/eachdist.ini b/eachdist.ini index a9c30cac21e..5000f4f463a 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -37,6 +37,7 @@ packages= opentelemetry-exporter-prometheus opentelemetry-exporter-otlp-json-common opentelemetry-exporter-otlp-json-file + opentelemetry-exporter-otlp-json-http opentelemetry-exporter-otlp-common opentelemetry-distro opentelemetry-proto-json From 34a656c467be54194da14bb20b3ce81081d57e60 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 16:18:55 -0500 Subject: [PATCH 08/20] fix bugs in helper functions --- .../exporter/otlp/json/http/_internal.py | 72 ++++++-- .../exporter/otlp/json/http/_log_exporter.py | 28 +-- .../otlp/json/http/metric_exporter.py | 28 +-- .../exporter/otlp/json/http/trace_exporter.py | 28 +-- .../tests/__init__.py | 2 + .../tests/test_internal.py | 170 +++++++++++++++++- 6 files changed, 243 insertions(+), 85 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py index d41c10b091c..db08818f7cc 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -1,13 +1,20 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + import logging import os from collections.abc import Mapping +from typing import TYPE_CHECKING, Literal +from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.json.http.version import __version__ from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, OTEL_EXPORTER_OTLP_COMPRESSION, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, @@ -15,18 +22,22 @@ ) from opentelemetry.util.re import parse_env_headers -_DEFAULT_ENDPOINT = "http://localhost:4318/" +if TYPE_CHECKING: + from opentelemetry.exporter.http.transport import BaseHTTPTransportFactory + from opentelemetry.exporter.http.transport._base import BaseHTTPTransport + +_DEFAULT_ENDPOINT = "http://localhost:4318" _DEFAULT_TIMEOUT = 10 _logger = logging.getLogger(__name__) -def _resolve_endpoint(endpoint_env_var: str, default_path: str) -> str: +def _resolve_endpoint(endpoint_env_var: str, default_path: Literal["v1/traces", "v1/metrics", "v1/logs"]) -> str: if endpoint := os.environ.get(endpoint_env_var): return endpoint - base_endpoint = os.environ.get( - OTEL_EXPORTER_OTLP_ENDPOINT, _DEFAULT_ENDPOINT + base_endpoint = ( + os.environ.get(OTEL_EXPORTER_OTLP_ENDPOINT) or _DEFAULT_ENDPOINT ) return f"{base_endpoint.removesuffix('/')}/{default_path}" @@ -41,9 +52,8 @@ def _resolve_headers( "User-Agent": "OTel-OTLP-JSON-Exporter-Python/" + __version__, } env_headers = parse_env_headers( - os.environ.get( - headers_env_var, os.environ.get(OTEL_EXPORTER_OTLP_HEADERS, "") - ), + os.environ.get(headers_env_var) + or os.environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""), liberal=True, ) headers_.update(env_headers) @@ -55,13 +65,22 @@ def _resolve_headers( def _resolve_timeout( timeout_env_var: str, ) -> float: - return float( - os.environ.get( - timeout_env_var, - os.environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, _DEFAULT_TIMEOUT), - ) + raw = ( + os.environ.get(timeout_env_var) + or os.environ.get(OTEL_EXPORTER_OTLP_TIMEOUT) + or _DEFAULT_TIMEOUT ) + try: + return float(raw) + except ValueError: + _logger.warning( + "Invalid timeout value %r, using default of %s seconds", + raw, + _DEFAULT_TIMEOUT, + ) + return float(_DEFAULT_TIMEOUT) + def _resolve_compression(compression_env_var: str) -> Compression: val = ( @@ -78,3 +97,32 @@ def _resolve_compression(compression_env_var: str) -> Compression: except ValueError: _logger.warning("Unsupported compression type: %s", val) return Compression.NONE + + +def _build_transport( + certificate_file: str | None, + client_key_file: str | None, + client_certificate_file: str | None, + certificate_env_var: str, + client_key_env_var: str, + client_certificate_env_var: str, + transport_factory: BaseHTTPTransportFactory = Urllib3HTTPTransport, +) -> BaseHTTPTransport: + verify: bool | str = certificate_file or os.environ.get( + certificate_env_var, + os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + ) + client_key_file = client_key_file or os.environ.get( + client_key_env_var, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), + ) + client_certificate_file = client_certificate_file or os.environ.get( + client_certificate_env_var, + os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), + ) + return transport_factory( + verify=verify, + cert=(client_certificate_file, client_key_file) + if client_certificate_file and client_key_file + else client_certificate_file, + ) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py index da3e6f605c8..066e3cd835b 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py @@ -2,18 +2,17 @@ # SPDX-License-Identifier: Apache-2.0 import logging -import os from collections.abc import Mapping, Sequence from typing import overload from opentelemetry.exporter.http.transport._base import BaseHTTPTransport -from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient from opentelemetry.exporter.otlp.json.common._internal._log_encoder import ( encode_logs, ) from opentelemetry.exporter.otlp.json.http._internal import ( + _build_transport, _resolve_compression, _resolve_endpoint, _resolve_headers, @@ -26,9 +25,6 @@ ) from opentelemetry.sdk._shared_internal import DuplicateFilter from opentelemetry.sdk.environment_variables import ( - OTEL_EXPORTER_OTLP_CERTIFICATE, - OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, - OTEL_EXPORTER_OTLP_CLIENT_KEY, OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, @@ -83,27 +79,13 @@ def __init__( *, _transport: BaseHTTPTransport | None = None, ) -> None: - verify: bool | str = certificate_file or os.environ.get( + transport = _transport or _build_transport( + certificate_file, + client_key_file, + client_certificate_file, OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, - os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), - ) - client_key_file = client_key_file or os.environ.get( OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), - ) - client_certificate_file = client_certificate_file or os.environ.get( OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), - ) - transport = ( - _transport - if _transport - else Urllib3HTTPTransport( - verify=verify, - cert=(client_certificate_file, client_key_file) - if client_certificate_file and client_key_file - else client_certificate_file, - ) ) self._client = OTLPHTTPClient( transport=transport, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py index 73a42bbbe63..5e65cff5dcd 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -2,12 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 import logging -import os from collections.abc import Mapping from typing import overload from opentelemetry.exporter.http.transport._base import BaseHTTPTransport -from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.common._aggregation import ( _get_aggregation, @@ -19,15 +17,13 @@ split_metrics_data, ) from opentelemetry.exporter.otlp.json.http._internal import ( + _build_transport, _resolve_compression, _resolve_endpoint, _resolve_headers, _resolve_timeout, ) from opentelemetry.sdk.environment_variables import ( - OTEL_EXPORTER_OTLP_CERTIFICATE, - OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, - OTEL_EXPORTER_OTLP_CLIENT_KEY, OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, @@ -105,27 +101,13 @@ def __init__( preferred_temporality=_get_temporality(preferred_temporality), preferred_aggregation=_get_aggregation(preferred_aggregation), ) - verify: bool | str = certificate_file or os.environ.get( + transport = _transport or _build_transport( + certificate_file, + client_key_file, + client_certificate_file, OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, - os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), - ) - client_key_file = client_key_file or os.environ.get( OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), - ) - client_certificate_file = client_certificate_file or os.environ.get( OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), - ) - transport = ( - _transport - if _transport - else Urllib3HTTPTransport( - verify=verify, - cert=(client_certificate_file, client_key_file) - if client_certificate_file and client_key_file - else client_certificate_file, - ) ) self._client = OTLPHTTPClient( transport=transport, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py index f43e4171838..f0ca95894fa 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py @@ -2,27 +2,23 @@ # SPDX-License-Identifier: Apache-2.0 import logging -import os from collections.abc import Mapping, Sequence from typing import overload from opentelemetry.exporter.http.transport._base import BaseHTTPTransport -from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient from opentelemetry.exporter.otlp.json.common._internal.trace_encoder import ( encode_spans, ) from opentelemetry.exporter.otlp.json.http._internal import ( + _build_transport, _resolve_compression, _resolve_endpoint, _resolve_headers, _resolve_timeout, ) from opentelemetry.sdk.environment_variables import ( - OTEL_EXPORTER_OTLP_CERTIFICATE, - OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, - OTEL_EXPORTER_OTLP_CLIENT_KEY, OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, @@ -78,27 +74,13 @@ def __init__( *, _transport: BaseHTTPTransport | None = None, ) -> None: - verify: bool | str = certificate_file or os.environ.get( + transport = _transport or _build_transport( + certificate_file, + client_key_file, + client_certificate_file, OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, - os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), - ) - client_key_file = client_key_file or os.environ.get( OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), - ) - client_certificate_file = client_certificate_file or os.environ.get( OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), - ) - transport = ( - _transport - if _transport - else Urllib3HTTPTransport( - verify=verify, - cert=(client_certificate_file, client_key_file) - if client_certificate_file and client_key_file - else client_certificate_file, - ) ) self._client = OTLPHTTPClient( transport=transport, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py index e69de29bb2d..e57cf4aba95 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py index 570a5b605bd..b5fad776c5a 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py @@ -6,11 +6,13 @@ import os import unittest from logging import WARNING -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.json.http._internal import ( _DEFAULT_TIMEOUT, + _build_transport, _resolve_compression, _resolve_endpoint, _resolve_headers, @@ -18,10 +20,16 @@ ) from opentelemetry.exporter.otlp.json.http.version import __version__ from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_CLIENT_KEY, OTEL_EXPORTER_OTLP_COMPRESSION, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TIMEOUT, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_HEADERS, @@ -66,6 +74,14 @@ def test_resolve_endpoint(self): "v1/traces", "http://base:4318/v1/traces", ), + ( + "empty_base_falls_back_to_default", + { + OTEL_EXPORTER_OTLP_ENDPOINT: "", + }, + "v1/traces", + "http://localhost:4318/v1/traces", + ), ( "default_traces", {}, @@ -107,6 +123,16 @@ def test_resolve_headers(self): None, {**_BASE_HEADERS, "api-key": "per-signal"}, ), + # empty per-signal var falls back to (inherits) the general one + ( + "empty_per_signal_falls_back_to_general", + { + OTEL_EXPORTER_OTLP_HEADERS: "api-key=general", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "", + }, + None, + {**_BASE_HEADERS, "api-key": "general"}, + ), # explicit arg wins over env and base, by exact key ( "explicit_arg_overrides", @@ -137,22 +163,54 @@ def test_resolve_timeout(self): OTEL_EXPORTER_OTLP_TIMEOUT: "7", }, 5.0, + False, ), ( "falls_back_to_general", {OTEL_EXPORTER_OTLP_TIMEOUT: "7"}, 7.0, + False, ), ( "fractional", {OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "2.5"}, 2.5, + False, + ), + ("default", {}, float(_DEFAULT_TIMEOUT), False), + ( + "empty_per_signal_falls_back_to_general", + { + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "", + OTEL_EXPORTER_OTLP_TIMEOUT: "7", + }, + 7.0, + False, + ), + ( + "empty_falls_back_to_default", + {OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: ""}, + float(_DEFAULT_TIMEOUT), + False, + ), + ( + "invalid_value_logs_and_defaults", + {OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "not-a-number"}, + float(_DEFAULT_TIMEOUT), + True, ), - ("default", {}, float(_DEFAULT_TIMEOUT)), ] - for label, env, expected in cases: + for label, env, expected, errors in cases: with self.subTest(label), patch.dict(os.environ, env, clear=True): - result = _resolve_timeout(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT) + if errors: + with self.assertLogs(level=WARNING): + result = _resolve_timeout( + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT + ) + else: + result = _resolve_timeout( + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT + ) self.assertEqual(result, expected) self.assertIsInstance(result, float) @@ -214,3 +272,107 @@ def test_resolve_compression(self): else: result = _resolve_compression(env_var) self.assertEqual(result, expected) + + +class TestBuildTransport(unittest.TestCase): + def test_default_transport_factory_is_urllib3(self): + with patch.dict(os.environ, {}, clear=True): + result = _build_transport( + None, + None, + None, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + ) + self.assertIsInstance(result, Urllib3HTTPTransport) + + def test_build_transport(self): + cases = [ + ( + "explicit_args_win", + { + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE: "env-cert.pem", + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY: "env-key.pem", + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE: "env-cert2.pem", + }, + "arg-cert.pem", + "arg-key.pem", + "arg-cert2.pem", + "arg-cert.pem", + ("arg-cert2.pem", "arg-key.pem"), + ), + ( + "per_signal_env_wins_over_general", + { + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE: "per-signal-cert.pem", + OTEL_EXPORTER_OTLP_CERTIFICATE: "general-cert.pem", + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY: "per-signal-key.pem", + OTEL_EXPORTER_OTLP_CLIENT_KEY: "general-key.pem", + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE: "per-signal-cert2.pem", + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: "general-cert2.pem", + }, + None, + None, + None, + "per-signal-cert.pem", + ("per-signal-cert2.pem", "per-signal-key.pem"), + ), + ( + "falls_back_to_general_env", + { + OTEL_EXPORTER_OTLP_CERTIFICATE: "general-cert.pem", + OTEL_EXPORTER_OTLP_CLIENT_KEY: "general-key.pem", + OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: "general-cert2.pem", + }, + None, + None, + None, + "general-cert.pem", + ("general-cert2.pem", "general-key.pem"), + ), + ( + "defaults_verify_true_no_cert", + {}, + None, + None, + None, + True, + None, + ), + ( + "only_cert_file_not_tupled", + { + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE: "cert2.pem", + }, + None, + None, + None, + True, + "cert2.pem", + ), + ] + for ( + label, + env, + certificate_file, + client_key_file, + client_certificate_file, + expected_verify, + expected_cert, + ) in cases: + with self.subTest(label), patch.dict(os.environ, env, clear=True): + mock_factory = MagicMock() + result = _build_transport( + certificate_file, + client_key_file, + client_certificate_file, + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + transport_factory=mock_factory, + ) + mock_factory.assert_called_once_with( + verify=expected_verify, cert=expected_cert + ) + self.assertIs(result, mock_factory.return_value) From 7b00709d945a354643fcfe2a4b746c474e06aa74 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 18:48:11 -0500 Subject: [PATCH 09/20] update batch splitting unit tests --- .../_internal/metrics_encoder/__init__.py | 39 +- .../tests/test_metrics_split.py | 424 +++++++++++++----- .../exporter/otlp/json/http/_internal.py | 5 +- 3 files changed, 354 insertions(+), 114 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py index 691b40e05a7..f6dda95ac4a 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/src/opentelemetry/exporter/otlp/json/common/_internal/metrics_encoder/__init__.py @@ -73,7 +73,13 @@ _logger = logging.getLogger(__name__) -_METRIC_DATA_FIELDS = ("gauge", "sum", "histogram", "exponential_histogram") +_METRIC_DATA_FIELDS = ( + "gauge", + "sum", + "histogram", + "exponential_histogram", + "summary", +) def encode_metrics(data: MetricsData) -> JSONExportMetricsServiceRequest: @@ -135,6 +141,9 @@ def split_metrics_data( ) for data_point in data_points: + data_points_batch.append(data_point) + batch_size += 1 + if batch_size >= max_export_batch_size: yield JSONExportMetricsServiceRequest( resource_metrics=resource_metrics_batch @@ -148,8 +157,15 @@ def split_metrics_data( resource_metrics, scope_metrics, metric, field_name ) batch_size = 0 - data_points_batch.append(data_point) - batch_size += 1 + + # A metric whose last point exactly filled the previous batch + # leaves a dangling empty batch here. + _prune_if_empty( + resource_metrics_batch, + scope_metrics_batch, + metrics_batch, + data_points_batch, + ) if batch_size > 0: yield JSONExportMetricsServiceRequest( @@ -220,6 +236,23 @@ def _build_empty_metric_batches( ) +def _prune_if_empty( + resource_metrics_batch: list[JSONResourceMetrics], + scope_metrics_batch: list[JSONScopeMetrics], + metrics_batch: list[JSONMetric], + data_points_batch: list, +) -> None: + if data_points_batch: + return + metrics_batch.pop() + if metrics_batch: + return + scope_metrics_batch.pop() + if scope_metrics_batch: + return + resource_metrics_batch.pop() + + def _encode_resource_metrics( rm: ResourceMetrics, ) -> JSONResourceMetrics: diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py index 5611c58f49a..295b155c55d 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_split.py @@ -41,9 +41,7 @@ from . import ( START_TIME, TIME, - make_exponential_histogram, - make_gauge, - make_histogram, + assert_proto_json_equal, make_metrics_data, make_sum, ) @@ -95,21 +93,9 @@ def _exponential_histogram_dp(count: int) -> ExponentialHistogramDataPoint: ) -def _sum_metric(name: str, values: list[int]) -> Metric: - return Metric( - name=name, - description="desc", - unit="s", - data=Sum( - data_points=[_number_dp(v) for v in values], - aggregation_temporality=AggregationTemporality.CUMULATIVE, - is_monotonic=True, - ), - ) - - -def _metric_of_type(field_name: str, count: int) -> Metric: - values = list(range(1, count + 1)) +def _metric_of_type( + field_name: str, values: list[int], name: str | None = None +) -> Metric: match field_name: case "gauge": data = Gauge(data_points=[_number_dp(v) for v in values]) @@ -129,7 +115,9 @@ def _metric_of_type(field_name: str, count: int) -> Metric: data_points=[_exponential_histogram_dp(v) for v in values], aggregation_temporality=AggregationTemporality.CUMULATIVE, ) - return Metric(name=field_name, description="desc", unit="u", data=data) + return Metric( + name=name or field_name, description="desc", unit="u", data=data + ) def _scope_metrics(name: str, metrics: list[Metric]) -> ScopeMetrics: @@ -154,7 +142,7 @@ def _resource_metrics( ) -def _data_point_field(data_point: JSONNumberDataPoint) -> int | float: +def _data_point_value(data_point: JSONNumberDataPoint) -> int | float: return ( data_point.as_int if data_point.as_int is not None @@ -162,15 +150,16 @@ def _data_point_field(data_point: JSONNumberDataPoint) -> int | float: ) -def _all_values(request: JSONExportMetricsServiceRequest) -> list[int | float]: - """Flatten every data point value in a request, in hierarchy order.""" +def _data_point_values( + request: JSONExportMetricsServiceRequest, +) -> list[int | float]: values = [] for resource_metrics in request.resource_metrics: for scope_metrics in resource_metrics.scope_metrics: for metric in scope_metrics.metrics: field_name = _get_metric_data_field_name(metric) for dp in getattr(metric, field_name).data_points: - values.append(_data_point_field(dp)) + values.append(_data_point_value(dp)) return values @@ -183,84 +172,78 @@ def _count_data_points(request: JSONExportMetricsServiceRequest) -> int: ) -class TestSplitMetricsData(unittest.TestCase): - def test_get_metric_data_field_name(self): - cases = { - "gauge": make_gauge(), - "sum": make_sum(), - "histogram": make_histogram(), - "exponential_histogram": make_exponential_histogram(), - } - for expected, metric in cases.items(): - with self.subTest(field_name=expected): - encoded = _get_first_metric( - encode_metrics(make_metrics_data([metric])) - ) - self.assertEqual( - _get_metric_data_field_name(encoded), expected +def _flatten_with_context( + request: JSONExportMetricsServiceRequest, +) -> dict[int, dict]: + context = {} + for resource_metrics in request.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + field_name = _get_metric_data_field_name(metric) + for dp in getattr(metric, field_name).data_points: + context[id(dp)] = { + "data_point": dp, + "resource": resource_metrics.resource, + "resource_schema_url": resource_metrics.schema_url, + "scope": scope_metrics.scope, + "scope_schema_url": scope_metrics.schema_url, + "metric_name": metric.name, + "metric_unit": metric.unit, + "metric_description": metric.description, + "field_name": field_name, + } + return context + + +def _assert_no_empty_metrics( + batch: JSONExportMetricsServiceRequest, +) -> None: + for resource_metrics in batch.resource_metrics: + assert resource_metrics.scope_metrics, "empty scope_metrics list" + for scope_metrics in resource_metrics.scope_metrics: + assert scope_metrics.metrics, "empty metrics list" + for metric in scope_metrics.metrics: + field_name = _get_metric_data_field_name(metric) + data_points = getattr(metric, field_name).data_points + assert data_points, ( + f"metric {metric.name!r} has no data points" ) - def test_get_metric_data_field_name_unsupported(self): - with self.assertLogs(level=WARNING): - encoded = _get_first_metric( - encode_metrics( - make_metrics_data( - [ - Metric( - name="x", description="d", unit="u", data=None - ) - ] - ) - ) - ) - self.assertIsNone(_get_metric_data_field_name(encoded)) - def test_none_batch_size_yields_original_unchanged(self): +class TestSplitMetricsData(unittest.TestCase): + def test_none_batch_size_yields_original(self): request = encode_metrics(make_metrics_data([make_sum(value=1)])) batches = list(split_metrics_data(request, None)) self.assertEqual(len(batches), 1) self.assertIs(batches[0], request) - def test_split_single_metric_even(self): - request = encode_metrics( - make_metrics_data([_sum_metric("s", [0, 1, 2, 3])]) - ) - batches = list(split_metrics_data(request, 2)) - self.assertEqual(len(batches), 2) - self.assertEqual([_all_values(b) for b in batches], [[0, 1], [2, 3]]) - - def test_split_single_metric_uneven(self): - request = encode_metrics( - make_metrics_data([_sum_metric("s", [0, 1, 2, 3, 4])]) - ) - batches = list(split_metrics_data(request, 2)) - self.assertEqual( - [_all_values(b) for b in batches], [[0, 1], [2, 3], [4]] - ) - - def test_split_batch_size_one(self): - request = encode_metrics( - make_metrics_data([_sum_metric("s", [7, 8, 9])]) - ) - batches = list(split_metrics_data(request, 1)) - self.assertEqual([_all_values(b) for b in batches], [[7], [8], [9]]) - - def test_split_batch_larger_than_total(self): - request = encode_metrics( - make_metrics_data([_sum_metric("s", [0, 1, 2])]) - ) - batches = list(split_metrics_data(request, 100)) - self.assertEqual(len(batches), 1) - self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + def test_split_batch_sizes(self): + cases = [ + ("even", [0, 1, 2, 3], 2, [[0, 1], [2, 3]]), + ("uneven", [0, 1, 2, 3, 4], 2, [[0, 1], [2, 3], [4]]), + ("batch_size_one", [7, 8, 9], 1, [[7], [8], [9]]), + ("batch_larger_than_total", [0, 1, 2], 100, [[0, 1, 2]]), + ] + for label, values, batch_size, expected_batches in cases: + with self.subTest(case=label): + request = encode_metrics( + make_metrics_data([_metric_of_type("sum", values, "s")]) + ) + batches = list(split_metrics_data(request, batch_size)) + self.assertEqual(len(batches), len(expected_batches)) + self.assertEqual( + [_data_point_values(b) for b in batches], + expected_batches, + ) - def test_split_preserves_metric_metadata(self): + def test_split_preserves_metadata(self): request = encode_metrics( - make_metrics_data([_sum_metric("s", [0, 1, 2, 3, 4])]) + make_metrics_data([_metric_of_type("sum", [0, 1, 2, 3, 4], "s")]) ) for batch in split_metrics_data(request, 2): metric = _get_first_metric(batch) self.assertEqual(metric.name, "s") - self.assertEqual(metric.unit, "s") + self.assertEqual(metric.unit, "u") self.assertEqual(metric.description, "desc") self.assertIsNotNone(metric.sum) self.assertEqual( @@ -269,10 +252,45 @@ def test_split_preserves_metric_metadata(self): ) self.assertTrue(metric.sum.is_monotonic) - def test_split_across_metrics_in_scope(self): + def test_split_with_empty_metric(self): + empty_metric = Metric( + name="empty", + description="d", + unit="u", + data=Sum( + data_points=[], + aggregation_temporality=AggregationTemporality.CUMULATIVE, + is_monotonic=True, + ), + ) request = encode_metrics( make_metrics_data( - [_sum_metric("a", [0, 1]), _sum_metric("b", [2, 3])] + [ + _metric_of_type("sum", [0, 1], "a"), + empty_metric, + _metric_of_type("sum", [2, 3], "b"), + ] + ) + ) + batches = list(split_metrics_data(request, 100)) + self.assertEqual(len(batches), 1) + _assert_no_empty_metrics(batches[0]) + names = [ + m.name + for rm in batches[0].resource_metrics + for sm in rm.scope_metrics + for m in sm.metrics + ] + self.assertEqual(names, ["a", "b"]) + self.assertEqual(_data_point_values(batches[0]), [0, 1, 2, 3]) + + def test_split_across_metrics(self): + request = encode_metrics( + make_metrics_data( + [ + _metric_of_type("sum", [0, 1], "a"), + _metric_of_type("sum", [2, 3], "b"), + ] ) ) batches = list(split_metrics_data(request, 3)) @@ -280,13 +298,13 @@ def test_split_across_metrics_in_scope(self): first_metrics = batches[0].resource_metrics[0].scope_metrics[0].metrics self.assertEqual([m.name for m in first_metrics], ["a", "b"]) - self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + self.assertEqual(_data_point_values(batches[0]), [0, 1, 2]) second_metrics = ( batches[1].resource_metrics[0].scope_metrics[0].metrics ) self.assertEqual([m.name for m in second_metrics], ["b"]) - self.assertEqual(_all_values(batches[1]), [3]) + self.assertEqual(_data_point_values(batches[1]), [3]) def test_split_across_scopes(self): request = encode_metrics( @@ -295,8 +313,14 @@ def test_split_across_scopes(self): _resource_metrics( 0, [ - _scope_metrics("s0", [_sum_metric("a", [0, 1])]), - _scope_metrics("s1", [_sum_metric("b", [2, 3])]), + _scope_metrics( + "s0", + [_metric_of_type("sum", [0, 1], "a")], + ), + _scope_metrics( + "s1", + [_metric_of_type("sum", [2, 3], "b")], + ), ], ) ] @@ -306,23 +330,35 @@ def test_split_across_scopes(self): self.assertEqual(len(batches), 2) self.assertEqual(len(batches[0].resource_metrics[0].scope_metrics), 2) - self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + self.assertEqual(_data_point_values(batches[0]), [0, 1, 2]) self.assertEqual(len(batches[1].resource_metrics[0].scope_metrics), 1) self.assertEqual( batches[1].resource_metrics[0].scope_metrics[0].scope.name, "s1" ) - self.assertEqual(_all_values(batches[1]), [3]) + self.assertEqual(_data_point_values(batches[1]), [3]) def test_split_across_resources(self): request = encode_metrics( MetricsData( resource_metrics=[ _resource_metrics( - 0, [_scope_metrics("s0", [_sum_metric("a", [0, 1])])] + 0, + [ + _scope_metrics( + "s0", + [_metric_of_type("sum", [0, 1], "a")], + ) + ], ), _resource_metrics( - 1, [_scope_metrics("s1", [_sum_metric("b", [2, 3])])] + 1, + [ + _scope_metrics( + "s1", + [_metric_of_type("sum", [2, 3], "b")], + ) + ], ), ] ) @@ -330,15 +366,18 @@ def test_split_across_resources(self): batches = list(split_metrics_data(request, 3)) self.assertEqual(len(batches), 2) self.assertEqual(len(batches[0].resource_metrics), 2) - self.assertEqual(_all_values(batches[0]), [0, 1, 2]) + self.assertEqual(_data_point_values(batches[0]), [0, 1, 2]) self.assertEqual(len(batches[1].resource_metrics), 1) - self.assertEqual(_all_values(batches[1]), [3]) + self.assertEqual(_data_point_values(batches[1]), [3]) def test_split_all_metric_types(self): for field_name in _METRIC_DATA_FIELDS: + # Exclude "summary" metrics (not generated by the SDK) + if field_name == "summary": + continue with self.subTest(field_name=field_name): request = encode_metrics( - make_metrics_data([_metric_of_type(field_name, 3)]) + make_metrics_data([_metric_of_type(field_name, [1, 2, 3])]) ) batches = list(split_metrics_data(request, 2)) self.assertEqual(len(batches), 2) @@ -350,7 +389,7 @@ def test_split_all_metric_types(self): _get_metric_data_field_name(metric), field_name ) - def test_split_preserves_all_data_points(self): + def test_split_preserves_data_points(self): request = encode_metrics( MetricsData( resource_metrics=[ @@ -360,39 +399,204 @@ def test_split_preserves_all_data_points(self): _scope_metrics( "s0", [ - _sum_metric("a", [0, 1, 2]), - _sum_metric("b", [3, 4]), + _metric_of_type("sum", [0, 1, 2], "a"), + _metric_of_type("sum", [3, 4], "b"), ], ), _scope_metrics( - "s1", [_sum_metric("c", [5, 6, 7])] + "s1", + [_metric_of_type("sum", [5, 6, 7], "c")], ), ], ), _resource_metrics( - 1, [_scope_metrics("s2", [_sum_metric("d", [8, 9])])] + 1, + [ + _scope_metrics( + "s2", + [_metric_of_type("sum", [8, 9], "d")], + ) + ], ), ] ) ) - expected = _all_values(request) + expected = _data_point_values(request) self.assertEqual(expected, list(range(10))) for batch_size in (1, 2, 3, 4, 7, 1000): with self.subTest(batch_size=batch_size): batches = list(split_metrics_data(request, batch_size)) - flattened = [v for b in batches for v in _all_values(b)] + flattened = [v for b in batches for v in _data_point_values(b)] self.assertEqual(flattened, expected) self.assertEqual( sum(_count_data_points(b) for b in batches), len(expected) ) for batch in batches: self.assertLessEqual(_count_data_points(batch), batch_size) + _assert_no_empty_metrics(batch) + + def test_split_preserves_hierarchy_and_attributes(self): + cases = [ + ( + "single_metric", + encode_metrics( + make_metrics_data( + [_metric_of_type("sum", [0, 1, 2, 3, 4], "only")] + ) + ), + 5, + ), + ( + "multi_scope_same_resource", + encode_metrics( + MetricsData( + resource_metrics=[ + _resource_metrics( + 0, + [ + _scope_metrics( + "s0", + [_metric_of_type("gauge", [1, 2, 3])], + ), + _scope_metrics( + "s1", + [_metric_of_type("histogram", [1, 2])], + ), + ], + ) + ] + ) + ), + 5, + ), + ( + "multi_resource_mixed_types", + encode_metrics( + MetricsData( + resource_metrics=[ + _resource_metrics( + 0, + [ + _scope_metrics( + "s0", + [ + _metric_of_type( + "sum", [0, 1, 2], "a" + ), + _metric_of_type( + "histogram", [1, 2] + ), + ], + ), + _scope_metrics( + "s1", + [_metric_of_type("gauge", [1, 2, 3])], + ), + ], + ), + _resource_metrics( + 1, + [ + _scope_metrics( + "s2", + [ + _metric_of_type( + "exponential_histogram", + [1, 2], + ) + ], + ) + ], + ), + ] + ) + ), + 10, + ), + ( + "empty_metric_interspersed", + encode_metrics( + make_metrics_data( + [ + _metric_of_type("sum", [0, 1], "a"), + Metric( + name="empty", + description="d", + unit="u", + data=Sum( + data_points=[], + aggregation_temporality=( + AggregationTemporality.CUMULATIVE + ), + is_monotonic=True, + ), + ), + _metric_of_type("sum", [2, 3], "b"), + ] + ) + ), + 4, + ), + ] + + for label, request, expected_count in cases: + with self.subTest(case=label): + ground_truth = _flatten_with_context(request) + self.assertEqual(len(ground_truth), expected_count) + + for batch_size in (1, 2, 3, 4, 7, 1000): + with self.subTest(case=label, batch_size=batch_size): + seen: dict[int, dict] = {} + for batch in split_metrics_data(request, batch_size): + _assert_no_empty_metrics(batch) + batch_context = _flatten_with_context(batch) + for dp_id in batch_context: + self.assertNotIn( + dp_id, + seen, + "data point yielded in more than one batch", + ) + seen.update(batch_context) + + self.assertEqual(set(seen), set(ground_truth)) + for dp_id, ctx in seen.items(): + expected = ground_truth[dp_id] + self.assertIs( + ctx["data_point"], expected["data_point"] + ) + assert_proto_json_equal( + self, ctx["resource"], expected["resource"] + ) + self.assertEqual( + ctx["resource_schema_url"], + expected["resource_schema_url"], + ) + assert_proto_json_equal( + self, ctx["scope"], expected["scope"] + ) + self.assertEqual( + ctx["scope_schema_url"], + expected["scope_schema_url"], + ) + self.assertEqual( + ctx["metric_name"], expected["metric_name"] + ) + self.assertEqual( + ctx["metric_unit"], expected["metric_unit"] + ) + self.assertEqual( + ctx["metric_description"], + expected["metric_description"], + ) + self.assertEqual( + ctx["field_name"], expected["field_name"] + ) def test_split_skips_unsupported_metric(self): request = encode_metrics( make_metrics_data( [ - _sum_metric("good", [0, 1]), + _metric_of_type("sum", [0, 1], "good"), Metric(name="bad", description="d", unit="u", data=None), ] ) @@ -403,7 +607,7 @@ def test_split_skips_unsupported_metric(self): any("unsupported metric type" in m for m in log_ctx.output) ) self.assertEqual(len(batches), 1) - self.assertEqual(_all_values(batches[0]), [0, 1]) + self.assertEqual(_data_point_values(batches[0]), [0, 1]) names = [ m.name for b in batches @@ -417,7 +621,7 @@ def test_split_empty_request(self): request = JSONExportMetricsServiceRequest(resource_metrics=[]) self.assertEqual(list(split_metrics_data(request, 5)), []) - def test_split_metric_with_no_data_points(self): + def test_split_with_no_data_points(self): metric = Metric( name="empty", description="d", diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py index db08818f7cc..954c4888bac 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -32,7 +32,10 @@ _logger = logging.getLogger(__name__) -def _resolve_endpoint(endpoint_env_var: str, default_path: Literal["v1/traces", "v1/metrics", "v1/logs"]) -> str: +def _resolve_endpoint( + endpoint_env_var: str, + default_path: Literal["v1/traces", "v1/metrics", "v1/logs"], +) -> str: if endpoint := os.environ.get(endpoint_env_var): return endpoint From e9906be823c39d988b066e88d6ae6cccd10a08c4 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 22:42:28 -0500 Subject: [PATCH 10/20] add trace exporter unit tests --- .../exporter/http/transport/_base.py | 2 +- .../test-requirements.txt | 7 +- .../tests/__init__.py | 30 ++ .../tests/test_trace_exporter.py | 496 ++++++++++++++++++ 4 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py diff --git a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py index 685b3e5b927..74b7976a5d0 100644 --- a/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py +++ b/exporter/opentelemetry-exporter-http-transport/src/opentelemetry/exporter/http/transport/_base.py @@ -40,7 +40,7 @@ def headers(self) -> Mapping[str, str]: keys. Headers with multiple values are represented as a single string of comma separated values. - Implementations may raise an exception the returned headers are malformed. + Implementations may raise an exception if the returned headers are malformed. """ def text(self) -> str: diff --git a/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt index ebb8bf6ba8a..af86c7a6ca8 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt +++ b/exporter/opentelemetry-exporter-otlp-json-http/test-requirements.txt @@ -1,12 +1,17 @@ asgiref==3.7.2 +decorator==5.2.1 +h11==0.16.0 importlib-metadata==6.11.0 iniconfig==2.0.0 +mocket==3.14.1 packaging==24.0 pluggy==1.6.0 +puremagic==1.30 ; python_full_version < '3.12' +puremagic==2.2.0 ; python_full_version >= '3.12' py-cpuinfo==9.0.0 pytest==7.4.4 tomli==2.0.1 -typing_extensions==4.10.0 +typing_extensions==4.15.0 urllib3==2.2.2 wrapt==1.16.0 zipp==3.19.2 diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py index e57cf4aba95..e05ad351f69 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py @@ -1,2 +1,32 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from unittest.mock import Mock, patch + + +@contextmanager +def _mock_clock( + shutdown_event: Mock | None = None, +) -> Iterator[Callable[[float], None]]: + _now = [0.0] + + def advance(delta: float) -> None: + _now[0] += delta + + def get_time() -> float: + return _now[0] + + if shutdown_event is not None: + + def _wait(duration: float) -> bool: + advance(duration) + return False + + shutdown_event.wait.side_effect = _wait + + with patch( + "opentelemetry.exporter.otlp.common._http.time.time", + side_effect=get_time, + ): + yield advance diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py new file mode 100644 index 00000000000..9f5e587eacf --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py @@ -0,0 +1,496 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access,too-many-public-methods + +import gzip +import json +import os +import threading +import unittest +import zlib +from datetime import datetime, timezone +from email.utils import format_datetime +from unittest.mock import Mock, patch + +import urllib3.exceptions +from mocket import Mocket, Mocketizer, mocketize +from mocket.mocks.mockhttp import Entry, Response + +from opentelemetry.exporter.http.transport._urllib3 import ( + Urllib3HTTPTransport, +) +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.json.common.trace_encoder import encode_spans +from opentelemetry.exporter.otlp.json.http._internal import _build_transport +from opentelemetry.exporter.otlp.json.http.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, + SpanExportResult, +) +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.trace import Link, SpanContext, StatusCode, TraceFlags +from . import _mock_clock + + +_TEST_ENDPOINT = "http://localhost:4318/v1/traces" +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.http.trace_exporter" + + + +class TestOTLPSpanExporter(unittest.TestCase): + def setUp(self): + env_patcher = patch.dict(os.environ, {}, clear=True) + env_patcher.start() + self.addCleanup(env_patcher.stop) + + 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() + + @staticmethod + def _mocked_shutdown_event() -> Mock: + shutdown_event = Mock(spec=threading.Event) + shutdown_event.is_set.return_value = False + return shutdown_event + + @mocketize + def test_export_single_span(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + spans = self._make_span("my-span") + + result = exporter.export(spans) + + self.assertEqual(result, SpanExportResult.SUCCESS) + request = Mocket.last_request() + self.assertEqual(request.method, "POST") + self.assertEqual(request.path, "/v1/traces") + self.assertEqual( + json.loads(request.body), encode_spans(spans).to_dict() + ) + + @mocketize + def test_export_multiple_spans_same_resource(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + with self._tracer.start_as_current_span("first"): + pass + with self._tracer.start_as_current_span("second"): + pass + spans = self._finished_spans() + + result = exporter.export(spans) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 1) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_spans(spans).to_dict()) + total_spans = sum( + len(ss["spans"]) + for rs in body["resourceSpans"] + for ss in rs["scopeSpans"] + ) + self.assertEqual(total_spans, 2) + + @mocketize + def test_export_spans_different_resources(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + spans = [] + for name, host in (("from-a", "a"), ("from-b", "b")): + in_memory = InMemorySpanExporter() + provider = TracerProvider(resource=Resource({"host": host})) + provider.add_span_processor(SimpleSpanProcessor(in_memory)) + tracer = provider.get_tracer(__name__) + with tracer.start_as_current_span(name): + pass + spans.extend(in_memory.get_finished_spans()) + + result = exporter.export(spans) + + self.assertEqual(result, SpanExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_spans(spans).to_dict()) + self.assertEqual(len(body["resourceSpans"]), 2) + + @mocketize + def test_export_rich_span(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + 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.ERROR, "boom") + spans = self._finished_spans() + + result = exporter.export(spans) + + self.assertEqual(result, SpanExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_spans(spans).to_dict()) + + @mocketize + def test_export_empty_sequence(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + + result = exporter.export([]) + + self.assertEqual(result, SpanExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_spans([]).to_dict()) + + @mocketize + def test_default_endpoint_and_headers(self): + Entry.single_register( + Entry.POST, "http://localhost:4318/v1/traces", status=200 + ) + exporter = OTLPSpanExporter() + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + headers = Mocket.last_request().headers + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith( + "OTel-OTLP-JSON-Exporter-Python/" + ) + ) + + @mocketize + def test_custom_endpoint(self): + url = "http://custom.example:9999/v1/traces" + Entry.single_register(Entry.POST, url, status=200) + exporter = OTLPSpanExporter(endpoint=url) + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + + @mocketize + def test_custom_headers(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter( + endpoint=_TEST_ENDPOINT, headers={"x-api-key": "secret"} + ) + + exporter.export(self._make_span()) + + headers = Mocket.last_request().headers + self.assertEqual(headers["x-api-key"], "secret") + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith( + "OTel-OTLP-JSON-Exporter-Python/" + ) + ) + + @mocketize + def test_custom_transport(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + custom_transport = Urllib3HTTPTransport() + + with patch( + "opentelemetry.exporter.otlp.json.http.trace_exporter._build_transport" + ) as mock_build_transport: + exporter = OTLPSpanExporter( + endpoint=_TEST_ENDPOINT, _transport=custom_transport + ) + + mock_build_transport.assert_not_called() + self.assertIs(exporter._client._transport, custom_transport) + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 1) + + @mocketize + def test_custom_timeout(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, timeout=7.5) + + with patch.object( + exporter._client._transport, + "request", + wraps=exporter._client._transport.request, + ) as mock_request: + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertAlmostEqual( + mock_request.call_args.kwargs["timeout"], 7.5, delta=0.5 + ) + + @mocketize + def test_certificate_args(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + + with patch( + "opentelemetry.exporter.otlp.json.http.trace_exporter._build_transport", + wraps=_build_transport, + ) as mock_build_transport: + exporter = OTLPSpanExporter( + endpoint=_TEST_ENDPOINT, + certificate_file="ca.pem", + client_key_file="client-key.pem", + client_certificate_file="client-cert.pem", + ) + + mock_build_transport.assert_called_once_with( + "ca.pem", + "client-key.pem", + "client-cert.pem", + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, + ) + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + + def test_compression_options(self): + cases = ( + (Compression.NONE, None, lambda data: data), + (Compression.GZIP, "gzip", gzip.decompress), + (Compression.DEFLATE, "deflate", zlib.decompress), + ) + for compression, expected_encoding, decompress in cases: + with self.subTest(compression=compression), Mocketizer(): + Entry.single_register( + Entry.POST, _TEST_ENDPOINT, status=200 + ) + exporter = OTLPSpanExporter( + endpoint=_TEST_ENDPOINT, compression=compression + ) + transport = exporter._client._transport + self._in_memory.clear() + spans = self._make_span() + + with patch.object( + transport, "request", wraps=transport.request + ) as mock_request: + result = exporter.export(spans) + + self.assertEqual(result, SpanExportResult.SUCCESS) + sent_headers = mock_request.call_args.kwargs["headers"] + if expected_encoding is None: + self.assertNotIn("Content-Encoding", sent_headers) + else: + self.assertEqual( + sent_headers["Content-Encoding"], expected_encoding + ) + sent_data = mock_request.call_args.kwargs["data"] + decompressed = decompress(sent_data) + self.assertEqual( + json.loads(decompressed), encode_spans(spans).to_dict() + ) + + def test_export_retryable_status_codes(self): + for status_code in (429, 502, 503, 504): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=status_code), + Response(status=200), + ) + exporter = OTLPSpanExporter( + endpoint=_TEST_ENDPOINT, timeout=30.0 + ) + shutdown_event = self._mocked_shutdown_event() + exporter._client._shutdown_event = shutdown_event + self._in_memory.clear() + + with _mock_clock(shutdown_event): + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once() + (wait_arg,) = shutdown_event.wait.call_args.args + self.assertGreaterEqual(wait_arg, 0.8) + self.assertLessEqual(wait_arg, 1.2) + + @mocketize + def test_export_max_retries(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + *[Response(status=503)] * 6, + ) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, timeout=1000.0) + shutdown_event = self._mocked_shutdown_event() + exporter._client._shutdown_event = shutdown_event + + with _mock_clock(shutdown_event): + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 6) + self.assertEqual(shutdown_event.wait.call_count, 5) + + @mocketize + def test_export_retry_after_header(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=429, headers={"Retry-After": "5"}), + Response(status=200), + ) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, timeout=60.0) + shutdown_event = self._mocked_shutdown_event() + shutdown_event.wait.return_value = False + exporter._client._shutdown_event = shutdown_event + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once_with(5.0) + + @mocketize + def test_export_retry_after_header_http_date(self): + base = 1_700_000_000.0 + retry_at = format_datetime( + datetime.fromtimestamp(base + 30, timezone.utc), usegmt=True + ) + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=503, headers={"Retry-After": retry_at}), + Response(status=200), + ) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, timeout=120.0) + shutdown_event = self._mocked_shutdown_event() + shutdown_event.wait.return_value = False + exporter._client._shutdown_event = shutdown_event + + with patch( + "opentelemetry.exporter.otlp.common._http.time.time", + return_value=base, + ): + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once_with(30.0) + + def test_export_non_retryable_status_codes(self): + for status_code in (400, 401, 403, 404, 408, 500, 501): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.single_register( + Entry.POST, _TEST_ENDPOINT, status=status_code + ) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + self._in_memory.clear() + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 1) + + @mocketize + def test_export_connection_error(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + urllib3.exceptions.ProtocolError("simulated reset"), + Response(status=200), + ) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, timeout=5.0) + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + + @mocketize + def test_export_after_shutdown(self): + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + exporter.shutdown() + + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.FAILURE) + self.assertIsNone(Mocket.last_request()) + + def test_shutdown_idempotent(self): + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + exporter.shutdown() + + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + exporter.shutdown() + + # pylint: disable-next=no-self-use + def test_shutdown_closes_transport(self): + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + + with patch.object(exporter._client._transport, "close") as mock_close: + exporter.shutdown() + + mock_close.assert_called_once() + + @mocketize + def test_force_flush(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + + self.assertTrue(exporter.force_flush()) + exporter.export(self._make_span()) + self.assertTrue(exporter.force_flush()) + + @mocketize + def test_export_encoding_failure(self): + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + + with ( + patch( + "opentelemetry.exporter.otlp.json.http.trace_exporter.encode_spans", + side_effect=ValueError("boom"), + ), + self.assertLogs(_LOGGER_NAME, level="ERROR"), + ): + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.FAILURE) + self.assertIsNone(Mocket.last_request()) From aecbfe19ec7176726f0f566161a480b2f4e31be9 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 22:48:34 -0500 Subject: [PATCH 11/20] fix formatting --- .../tests/test_trace_exporter.py | 158 ++++++++++++------ 1 file changed, 105 insertions(+), 53 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py index 9f5e587eacf..13bba3153be 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py @@ -27,9 +27,15 @@ OTLPSpanExporter, ) from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_TIMEOUT, OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_HEADERS, + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, ) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider @@ -41,14 +47,13 @@ InMemorySpanExporter, ) from opentelemetry.trace import Link, SpanContext, StatusCode, TraceFlags -from . import _mock_clock +from . import _mock_clock _TEST_ENDPOINT = "http://localhost:4318/v1/traces" _LOGGER_NAME = "opentelemetry.exporter.otlp.json.http.trace_exporter" - class TestOTLPSpanExporter(unittest.TestCase): def setUp(self): env_patcher = patch.dict(os.environ, {}, clear=True) @@ -189,38 +194,72 @@ def test_default_endpoint_and_headers(self): headers = Mocket.last_request().headers self.assertEqual(headers["content-type"], "application/json") self.assertTrue( - headers["user-agent"].startswith( - "OTel-OTLP-JSON-Exporter-Python/" - ) + headers["user-agent"].startswith("OTel-OTLP-JSON-Exporter-Python/") ) - @mocketize def test_custom_endpoint(self): url = "http://custom.example:9999/v1/traces" - Entry.single_register(Entry.POST, url, status=200) - exporter = OTLPSpanExporter(endpoint=url) + cases = ( + ("constructor", {}, {"endpoint": url}), + ( + "generic_env", + {OTEL_EXPORTER_OTLP_ENDPOINT: "http://custom.example:9999"}, + {}, + ), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: url}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, url, status=200) + exporter = OTLPSpanExporter(**kwargs) + self._in_memory.clear() - result = exporter.export(self._make_span()) + result = exporter.export(self._make_span()) - self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertEqual(result, SpanExportResult.SUCCESS) - @mocketize def test_custom_headers(self): - Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) - exporter = OTLPSpanExporter( - endpoint=_TEST_ENDPOINT, headers={"x-api-key": "secret"} + cases = ( + ("constructor", {}, {"headers": {"x-api-key": "secret"}}), + ( + "generic_env", + {OTEL_EXPORTER_OTLP_HEADERS: "x-api-key=secret"}, + {}, + ), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_TRACES_HEADERS: "x-api-key=secret"}, + {}, + ), ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, **kwargs) + self._in_memory.clear() - exporter.export(self._make_span()) + exporter.export(self._make_span()) - headers = Mocket.last_request().headers - self.assertEqual(headers["x-api-key"], "secret") - self.assertEqual(headers["content-type"], "application/json") - self.assertTrue( - headers["user-agent"].startswith( - "OTel-OTLP-JSON-Exporter-Python/" - ) - ) + headers = Mocket.last_request().headers + self.assertEqual(headers["x-api-key"], "secret") + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith( + "OTel-OTLP-JSON-Exporter-Python/" + ) + ) @mocketize def test_custom_transport(self): @@ -242,22 +281,37 @@ def test_custom_transport(self): self.assertEqual(result, SpanExportResult.SUCCESS) self.assertEqual(len(Mocket.request_list()), 1) - @mocketize def test_custom_timeout(self): - Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) - exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, timeout=7.5) + cases = ( + ("constructor", {}, {"timeout": 7.5}), + ("generic_env", {OTEL_EXPORTER_OTLP_TIMEOUT: "7.5"}, {}), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "7.5"}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT, **kwargs) + self._in_memory.clear() - with patch.object( - exporter._client._transport, - "request", - wraps=exporter._client._transport.request, - ) as mock_request: - result = exporter.export(self._make_span()) + with patch.object( + exporter._client._transport, + "request", + wraps=exporter._client._transport.request, + ) as mock_request: + result = exporter.export(self._make_span()) - self.assertEqual(result, SpanExportResult.SUCCESS) - self.assertAlmostEqual( - mock_request.call_args.kwargs["timeout"], 7.5, delta=0.5 - ) + self.assertEqual(result, SpanExportResult.SUCCESS) + self.assertAlmostEqual( + mock_request.call_args.kwargs["timeout"], 7.5, delta=0.5 + ) @mocketize def test_certificate_args(self): @@ -295,9 +349,7 @@ def test_compression_options(self): ) for compression, expected_encoding, decompress in cases: with self.subTest(compression=compression), Mocketizer(): - Entry.single_register( - Entry.POST, _TEST_ENDPOINT, status=200 - ) + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) exporter = OTLPSpanExporter( endpoint=_TEST_ENDPOINT, compression=compression ) @@ -350,6 +402,20 @@ def test_export_retryable_status_codes(self): self.assertGreaterEqual(wait_arg, 0.8) self.assertLessEqual(wait_arg, 1.2) + def test_export_non_retryable_status_codes(self): + for status_code in (400, 401, 403, 404, 408, 500, 501): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.single_register( + Entry.POST, _TEST_ENDPOINT, status=status_code + ) + exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) + self._in_memory.clear() + + result = exporter.export(self._make_span()) + + self.assertEqual(result, SpanExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 1) + @mocketize def test_export_max_retries(self): Entry.register( @@ -414,20 +480,6 @@ def test_export_retry_after_header_http_date(self): self.assertEqual(len(Mocket.request_list()), 2) shutdown_event.wait.assert_called_once_with(30.0) - def test_export_non_retryable_status_codes(self): - for status_code in (400, 401, 403, 404, 408, 500, 501): - with self.subTest(status_code=status_code), Mocketizer(): - Entry.single_register( - Entry.POST, _TEST_ENDPOINT, status=status_code - ) - exporter = OTLPSpanExporter(endpoint=_TEST_ENDPOINT) - self._in_memory.clear() - - result = exporter.export(self._make_span()) - - self.assertEqual(result, SpanExportResult.FAILURE) - self.assertEqual(len(Mocket.request_list()), 1) - @mocketize def test_export_connection_error(self): Entry.register( From 0a9cc3f71c191c16e9ffce67f48743cc00c3a707 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 22:58:05 -0500 Subject: [PATCH 12/20] add log exporter tests --- .../tests/test_log_exporter.py | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py new file mode 100644 index 00000000000..ec343195d00 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py @@ -0,0 +1,558 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access,too-many-public-methods + +import gzip +import json +import os +import threading +import unittest +import zlib +from datetime import datetime, timezone +from email.utils import format_datetime +from unittest.mock import Mock, patch + +import urllib3.exceptions +from mocket import Mocket, Mocketizer, mocketize +from mocket.mocks.mockhttp import Entry, Response + +from opentelemetry._logs import LogRecord, SeverityNumber +from opentelemetry.exporter.http.transport._urllib3 import ( + Urllib3HTTPTransport, +) +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.json.common._log_encoder import encode_logs +from opentelemetry.exporter.otlp.json.http._internal import _build_transport +from opentelemetry.exporter.otlp.json.http._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + LogRecordExportResult, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + OTEL_EXPORTER_OTLP_LOGS_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + TraceFlags, + set_span_in_context, +) + +from . import _mock_clock + +_TEST_ENDPOINT = "http://localhost:4318/v1/logs" +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.http._log_exporter" + + +class TestOTLPLogExporter(unittest.TestCase): + def setUp(self): + env_patcher = patch.dict(os.environ, {}, clear=True) + env_patcher.start() + self.addCleanup(env_patcher.stop) + + self._in_memory = InMemoryLogRecordExporter() + provider = LoggerProvider() + provider.add_log_record_processor( + SimpleLogRecordProcessor(self._in_memory) + ) + self._logger = provider.get_logger(__name__) + + def _finished_logs(self): + return list(self._in_memory.get_finished_logs()) + + def _make_log(self, body: str = "test-log"): + self._logger.emit( + LogRecord(body=body, severity_number=SeverityNumber.INFO) + ) + return self._finished_logs() + + @staticmethod + def _mocked_shutdown_event() -> Mock: + shutdown_event = Mock(spec=threading.Event) + shutdown_event.is_set.return_value = False + return shutdown_event + + @mocketize + def test_export_single_log(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + logs = self._make_log("my-log") + + result = exporter.export(logs) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + request = Mocket.last_request() + self.assertEqual(request.method, "POST") + self.assertEqual(request.path, "/v1/logs") + self.assertEqual(json.loads(request.body), encode_logs(logs).to_dict()) + + @mocketize + def test_export_multiple_logs_same_resource(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + self._logger.emit( + LogRecord(body="first", severity_number=SeverityNumber.INFO) + ) + self._logger.emit( + LogRecord(body="second", severity_number=SeverityNumber.INFO) + ) + logs = self._finished_logs() + + result = exporter.export(logs) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 1) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_logs(logs).to_dict()) + total_logs = sum( + len(sl["logRecords"]) + for rl in body["resourceLogs"] + for sl in rl["scopeLogs"] + ) + self.assertEqual(total_logs, 2) + + @mocketize + def test_export_logs_different_resources(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + logs = [] + for body, host in (("from-a", "a"), ("from-b", "b")): + in_memory = InMemoryLogRecordExporter() + provider = LoggerProvider(resource=Resource({"host": host})) + provider.add_log_record_processor( + SimpleLogRecordProcessor(in_memory) + ) + logger = provider.get_logger(__name__) + logger.emit( + LogRecord(body=body, severity_number=SeverityNumber.INFO) + ) + logs.extend(in_memory.get_finished_logs()) + + result = exporter.export(logs) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_logs(logs).to_dict()) + self.assertEqual(len(body["resourceLogs"]), 2) + + @mocketize + def test_export_rich_log(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + ctx = set_span_in_context( + NonRecordingSpan( + SpanContext( + trace_id=0x000000000000000000000000DEADBEEF, + span_id=0x00000000DEADBEF0, + is_remote=True, + trace_flags=TraceFlags(0x01), + ) + ) + ) + self._logger.emit( + LogRecord( + body="rich-log", + severity_text="ERROR", + severity_number=SeverityNumber.ERROR, + attributes={"http.method": "GET", "http.status_code": 500}, + context=ctx, + ) + ) + logs = self._finished_logs() + + result = exporter.export(logs) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_logs(logs).to_dict()) + + @mocketize + def test_export_empty_sequence(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + + result = exporter.export([]) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_logs([]).to_dict()) + + @mocketize + def test_default_endpoint_and_headers(self): + Entry.single_register( + Entry.POST, "http://localhost:4318/v1/logs", status=200 + ) + exporter = OTLPLogExporter() + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + headers = Mocket.last_request().headers + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith("OTel-OTLP-JSON-Exporter-Python/") + ) + + def test_custom_endpoint(self): + url = "http://custom.example:9999/v1/logs" + cases = ( + ("constructor", {}, {"endpoint": url}), + ( + "generic_env", + {OTEL_EXPORTER_OTLP_ENDPOINT: "http://custom.example:9999"}, + {}, + ), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: url}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, url, status=200) + exporter = OTLPLogExporter(**kwargs) + self._in_memory.clear() + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + + def test_custom_headers(self): + cases = ( + ("constructor", {}, {"headers": {"x-api-key": "secret"}}), + ( + "generic_env", + {OTEL_EXPORTER_OTLP_HEADERS: "x-api-key=secret"}, + {}, + ), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_LOGS_HEADERS: "x-api-key=secret"}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT, **kwargs) + self._in_memory.clear() + + exporter.export(self._make_log()) + + headers = Mocket.last_request().headers + self.assertEqual(headers["x-api-key"], "secret") + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith( + "OTel-OTLP-JSON-Exporter-Python/" + ) + ) + + @mocketize + def test_custom_transport(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + custom_transport = Urllib3HTTPTransport() + + with patch( + "opentelemetry.exporter.otlp.json.http._log_exporter._build_transport" + ) as mock_build_transport: + exporter = OTLPLogExporter( + endpoint=_TEST_ENDPOINT, _transport=custom_transport + ) + + mock_build_transport.assert_not_called() + self.assertIs(exporter._client._transport, custom_transport) + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 1) + + def test_custom_timeout(self): + cases = ( + ("constructor", {}, {"timeout": 7.5}), + ("generic_env", {OTEL_EXPORTER_OTLP_TIMEOUT: "7.5"}, {}), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_LOGS_TIMEOUT: "7.5"}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT, **kwargs) + self._in_memory.clear() + + with patch.object( + exporter._client._transport, + "request", + wraps=exporter._client._transport.request, + ) as mock_request: + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertAlmostEqual( + mock_request.call_args.kwargs["timeout"], 7.5, delta=0.5 + ) + + @mocketize + def test_certificate_args(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + + with patch( + "opentelemetry.exporter.otlp.json.http._log_exporter._build_transport", + wraps=_build_transport, + ) as mock_build_transport: + exporter = OTLPLogExporter( + endpoint=_TEST_ENDPOINT, + certificate_file="ca.pem", + client_key_file="client-key.pem", + client_certificate_file="client-cert.pem", + ) + + mock_build_transport.assert_called_once_with( + "ca.pem", + "client-key.pem", + "client-cert.pem", + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, + ) + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + + def test_compression_options(self): + cases = ( + (Compression.NONE, None, lambda data: data), + (Compression.GZIP, "gzip", gzip.decompress), + (Compression.DEFLATE, "deflate", zlib.decompress), + ) + for compression, expected_encoding, decompress in cases: + with self.subTest(compression=compression), Mocketizer(): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter( + endpoint=_TEST_ENDPOINT, compression=compression + ) + transport = exporter._client._transport + self._in_memory.clear() + logs = self._make_log() + + with patch.object( + transport, "request", wraps=transport.request + ) as mock_request: + result = exporter.export(logs) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + sent_headers = mock_request.call_args.kwargs["headers"] + if expected_encoding is None: + self.assertNotIn("Content-Encoding", sent_headers) + else: + self.assertEqual( + sent_headers["Content-Encoding"], expected_encoding + ) + sent_data = mock_request.call_args.kwargs["data"] + decompressed = decompress(sent_data) + self.assertEqual( + json.loads(decompressed), encode_logs(logs).to_dict() + ) + + def test_export_retryable_status_codes(self): + for status_code in (429, 502, 503, 504): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=status_code), + Response(status=200), + ) + exporter = OTLPLogExporter( + endpoint=_TEST_ENDPOINT, timeout=30.0 + ) + shutdown_event = self._mocked_shutdown_event() + exporter._client._shutdown_event = shutdown_event + self._in_memory.clear() + + with _mock_clock(shutdown_event): + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once() + (wait_arg,) = shutdown_event.wait.call_args.args + self.assertGreaterEqual(wait_arg, 0.8) + self.assertLessEqual(wait_arg, 1.2) + + def test_export_non_retryable_status_codes(self): + for status_code in (400, 401, 403, 404, 408, 500, 501): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.single_register( + Entry.POST, _TEST_ENDPOINT, status=status_code + ) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + self._in_memory.clear() + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 1) + + @mocketize + def test_export_max_retries(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + *[Response(status=503)] * 6, + ) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT, timeout=1000.0) + shutdown_event = self._mocked_shutdown_event() + exporter._client._shutdown_event = shutdown_event + + with _mock_clock(shutdown_event): + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 6) + self.assertEqual(shutdown_event.wait.call_count, 5) + + @mocketize + def test_export_retry_after_header(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=429, headers={"Retry-After": "5"}), + Response(status=200), + ) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT, timeout=60.0) + shutdown_event = self._mocked_shutdown_event() + shutdown_event.wait.return_value = False + exporter._client._shutdown_event = shutdown_event + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once_with(5.0) + + @mocketize + def test_export_retry_after_header_http_date(self): + base = 1_700_000_000.0 + retry_at = format_datetime( + datetime.fromtimestamp(base + 30, timezone.utc), usegmt=True + ) + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=503, headers={"Retry-After": retry_at}), + Response(status=200), + ) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT, timeout=120.0) + shutdown_event = self._mocked_shutdown_event() + shutdown_event.wait.return_value = False + exporter._client._shutdown_event = shutdown_event + + with patch( + "opentelemetry.exporter.otlp.common._http.time.time", + return_value=base, + ): + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once_with(30.0) + + @mocketize + def test_export_connection_error(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + urllib3.exceptions.ProtocolError("simulated reset"), + Response(status=200), + ) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT, timeout=5.0) + + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + + @mocketize + def test_export_after_shutdown(self): + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + exporter.shutdown() + + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.FAILURE) + self.assertIsNone(Mocket.last_request()) + + def test_shutdown_idempotent(self): + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + exporter.shutdown() + + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + exporter.shutdown() + + # pylint: disable-next=no-self-use + def test_shutdown_closes_transport(self): + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + + with patch.object(exporter._client._transport, "close") as mock_close: + exporter.shutdown() + + mock_close.assert_called_once() + + @mocketize + def test_force_flush(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + + self.assertTrue(exporter.force_flush()) + exporter.export(self._make_log()) + self.assertTrue(exporter.force_flush()) + + @mocketize + def test_export_encoding_failure(self): + exporter = OTLPLogExporter(endpoint=_TEST_ENDPOINT) + + with ( + patch( + "opentelemetry.exporter.otlp.json.http._log_exporter.encode_logs", + side_effect=ValueError("boom"), + ), + self.assertLogs(_LOGGER_NAME, level="ERROR"), + ): + result = exporter.export(self._make_log()) + + self.assertEqual(result, LogRecordExportResult.FAILURE) + self.assertIsNone(Mocket.last_request()) From 5d56407e3e98a6ff16d4227edce8458f933c6e68 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 23:09:01 -0500 Subject: [PATCH 13/20] add remaining tests --- .../tests/test_metric_exporter.py | 637 ++++++++++++++++++ 1 file changed, 637 insertions(+) create mode 100644 exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py new file mode 100644 index 00000000000..d09f4f06294 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py @@ -0,0 +1,637 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access,too-many-public-methods + +import gzip +import json +import os +import threading +import unittest +import zlib +from datetime import datetime, timezone +from email.utils import format_datetime +from unittest.mock import Mock, patch + +import urllib3.exceptions +from mocket import Mocket, Mocketizer, mocketize +from mocket.mocks.mockhttp import Entry, Response + +from opentelemetry.attributes import BoundedAttributes +from opentelemetry.exporter.http.transport._urllib3 import ( + Urllib3HTTPTransport, +) +from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( + split_metrics_data, +) +from opentelemetry.exporter.otlp.json.common.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.exporter.otlp.json.http._internal import _build_transport +from opentelemetry.exporter.otlp.json.http.metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.sdk.environment_variables import ( + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + OTEL_EXPORTER_OTLP_METRICS_HEADERS, + OTEL_EXPORTER_OTLP_METRICS_TIMEOUT, + OTEL_EXPORTER_OTLP_TIMEOUT, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + Metric, + MetricExportResult, + MetricsData, + NumberDataPoint, + ResourceMetrics, + ScopeMetrics, + Sum, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.test.metrictestutil import ( + _generate_gauge, + _generate_histogram, + _generate_sum, +) + +from . import _mock_clock + +_TEST_ENDPOINT = "http://localhost:4318/v1/metrics" +_LOGGER_NAME = "opentelemetry.exporter.otlp.json.http.metric_exporter" + + +def _make_metrics_data( + metric: Metric | None = None, + resource_attrs: dict | None = None, +) -> MetricsData: + return MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource(resource_attrs or {"service.name": "test"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("test-scope", "1.0"), + metrics=[metric or _generate_sum("requests", 42)], + schema_url="", + ) + ], + schema_url="", + ) + ] + ) + + +class TestOTLPMetricExporter(unittest.TestCase): + def setUp(self): + env_patcher = patch.dict(os.environ, {}, clear=True) + env_patcher.start() + self.addCleanup(env_patcher.stop) + + @staticmethod + def _mocked_shutdown_event() -> Mock: + shutdown_event = Mock(spec=threading.Event) + shutdown_event.is_set.return_value = False + return shutdown_event + + @mocketize + def test_export_single_metric(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + metrics_data = _make_metrics_data() + + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + request = Mocket.last_request() + self.assertEqual(request.method, "POST") + self.assertEqual(request.path, "/v1/metrics") + self.assertEqual( + json.loads(request.body), encode_metrics(metrics_data).to_dict() + ) + + @mocketize + def test_export_multiple_metrics_same_resource(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource({"service.name": "test"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("test-scope", "1.0"), + metrics=[ + _generate_sum("counter", 1), + _generate_gauge("gauge", 2), + ], + schema_url="", + ) + ], + schema_url="", + ) + ] + ) + + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 1) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_metrics(metrics_data).to_dict()) + total_metrics = sum( + len(sm["metrics"]) + for rm in body["resourceMetrics"] + for sm in rm["scopeMetrics"] + ) + self.assertEqual(total_metrics, 2) + + @mocketize + def test_export_metrics_different_resources(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource({"host": "a"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("test-scope", "1.0"), + metrics=[_generate_sum("from-a", 1)], + schema_url="", + ) + ], + schema_url="", + ), + ResourceMetrics( + resource=Resource({"host": "b"}), + scope_metrics=[ + ScopeMetrics( + scope=InstrumentationScope("test-scope", "1.0"), + metrics=[_generate_sum("from-b", 1)], + schema_url="", + ) + ], + schema_url="", + ), + ] + ) + + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_metrics(metrics_data).to_dict()) + self.assertEqual(len(body["resourceMetrics"]), 2) + + @mocketize + def test_export_rich_metrics(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + metrics_data = _make_metrics_data( + _generate_histogram( + "req.duration", + attributes=BoundedAttributes( + attributes={ + "http.method": "GET", + "http.status_code": 200, + } + ), + ) + ) + + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_metrics(metrics_data).to_dict()) + + @mocketize + def test_export_empty_metrics_data(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + metrics_data = MetricsData(resource_metrics=[]) + + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + body = json.loads(Mocket.last_request().body) + self.assertEqual(body, encode_metrics(metrics_data).to_dict()) + + @mocketize + def test_default_endpoint_and_headers(self): + Entry.single_register( + Entry.POST, "http://localhost:4318/v1/metrics", status=200 + ) + exporter = OTLPMetricExporter() + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + headers = Mocket.last_request().headers + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith("OTel-OTLP-JSON-Exporter-Python/") + ) + + def test_custom_endpoint(self): + url = "http://custom.example:9999/v1/metrics" + cases = ( + ("constructor", {}, {"endpoint": url}), + ( + "generic_env", + {OTEL_EXPORTER_OTLP_ENDPOINT: "http://custom.example:9999"}, + {}, + ), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: url}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, url, status=200) + exporter = OTLPMetricExporter(**kwargs) + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + + def test_custom_headers(self): + cases = ( + ("constructor", {}, {"headers": {"x-api-key": "secret"}}), + ( + "generic_env", + {OTEL_EXPORTER_OTLP_HEADERS: "x-api-key=secret"}, + {}, + ), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_METRICS_HEADERS: "x-api-key=secret"}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, **kwargs + ) + + exporter.export(_make_metrics_data()) + + headers = Mocket.last_request().headers + self.assertEqual(headers["x-api-key"], "secret") + self.assertEqual(headers["content-type"], "application/json") + self.assertTrue( + headers["user-agent"].startswith( + "OTel-OTLP-JSON-Exporter-Python/" + ) + ) + + @mocketize + def test_custom_transport(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + custom_transport = Urllib3HTTPTransport() + + with patch( + "opentelemetry.exporter.otlp.json.http.metric_exporter._build_transport" + ) as mock_build_transport: + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, _transport=custom_transport + ) + + mock_build_transport.assert_not_called() + self.assertIs(exporter._client._transport, custom_transport) + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 1) + + def test_custom_timeout(self): + cases = ( + ("constructor", {}, {"timeout": 7.5}), + ("generic_env", {OTEL_EXPORTER_OTLP_TIMEOUT: "7.5"}, {}), + ( + "per_signal_env", + {OTEL_EXPORTER_OTLP_METRICS_TIMEOUT: "7.5"}, + {}, + ), + ) + for label, env, kwargs in cases: + with ( + self.subTest(label), + patch.dict(os.environ, env, clear=True), + Mocketizer(), + ): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, **kwargs + ) + + with patch.object( + exporter._client._transport, + "request", + wraps=exporter._client._transport.request, + ) as mock_request: + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertAlmostEqual( + mock_request.call_args.kwargs["timeout"], 7.5, delta=0.5 + ) + + @mocketize + def test_certificate_args(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + + with patch( + "opentelemetry.exporter.otlp.json.http.metric_exporter._build_transport", + wraps=_build_transport, + ) as mock_build_transport: + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, + certificate_file="ca.pem", + client_key_file="client-key.pem", + client_certificate_file="client-cert.pem", + ) + + mock_build_transport.assert_called_once_with( + "ca.pem", + "client-key.pem", + "client-cert.pem", + OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, + OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, + ) + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + + def test_compression_options(self): + cases = ( + (Compression.NONE, None, lambda data: data), + (Compression.GZIP, "gzip", gzip.decompress), + (Compression.DEFLATE, "deflate", zlib.decompress), + ) + for compression, expected_encoding, decompress in cases: + with self.subTest(compression=compression), Mocketizer(): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, compression=compression + ) + transport = exporter._client._transport + metrics_data = _make_metrics_data() + + with patch.object( + transport, "request", wraps=transport.request + ) as mock_request: + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + sent_headers = mock_request.call_args.kwargs["headers"] + if expected_encoding is None: + self.assertNotIn("Content-Encoding", sent_headers) + else: + self.assertEqual( + sent_headers["Content-Encoding"], expected_encoding + ) + sent_data = mock_request.call_args.kwargs["data"] + decompressed = decompress(sent_data) + self.assertEqual( + json.loads(decompressed), + encode_metrics(metrics_data).to_dict(), + ) + + @mocketize + def test_export_batch_splitting(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=200), + Response(status=200), + Response(status=200), + ) + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, max_export_batch_size=2 + ) + data_points = [ + NumberDataPoint( + attributes=BoundedAttributes(attributes={"i": i}), + start_time_unix_nano=1641946015139533244, + time_unix_nano=1641946016139533244, + value=i, + ) + for i in range(5) + ] + metric = Metric( + name="requests", + description="foo", + unit="s", + data=Sum( + data_points=data_points, + aggregation_temporality=AggregationTemporality.CUMULATIVE, + is_monotonic=True, + ), + ) + metrics_data = _make_metrics_data(metric) + expected_batches = [ + batch.to_dict() + for batch in split_metrics_data(encode_metrics(metrics_data), 2) + ] + + result = exporter.export(metrics_data) + + self.assertEqual(result, MetricExportResult.SUCCESS) + requests = Mocket.request_list() + self.assertEqual(len(requests), len(expected_batches)) + self.assertEqual( + [json.loads(request.body) for request in requests], + expected_batches, + ) + + def test_export_retryable_status_codes(self): + for status_code in (429, 502, 503, 504): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=status_code), + Response(status=200), + ) + exporter = OTLPMetricExporter( + endpoint=_TEST_ENDPOINT, timeout=30.0 + ) + shutdown_event = self._mocked_shutdown_event() + exporter._client._shutdown_event = shutdown_event + + with _mock_clock(shutdown_event): + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once() + (wait_arg,) = shutdown_event.wait.call_args.args + self.assertGreaterEqual(wait_arg, 0.8) + self.assertLessEqual(wait_arg, 1.2) + + def test_export_non_retryable_status_codes(self): + for status_code in (400, 401, 403, 404, 408, 500, 501): + with self.subTest(status_code=status_code), Mocketizer(): + Entry.single_register( + Entry.POST, _TEST_ENDPOINT, status=status_code + ) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 1) + + @mocketize + def test_export_max_retries(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + *[Response(status=503)] * 6, + ) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT, timeout=1000.0) + shutdown_event = self._mocked_shutdown_event() + exporter._client._shutdown_event = shutdown_event + + with _mock_clock(shutdown_event): + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.FAILURE) + self.assertEqual(len(Mocket.request_list()), 6) + self.assertEqual(shutdown_event.wait.call_count, 5) + + @mocketize + def test_export_retry_after_header(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=429, headers={"Retry-After": "5"}), + Response(status=200), + ) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT, timeout=60.0) + shutdown_event = self._mocked_shutdown_event() + shutdown_event.wait.return_value = False + exporter._client._shutdown_event = shutdown_event + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once_with(5.0) + + @mocketize + def test_export_retry_after_header_http_date(self): + base = 1_700_000_000.0 + retry_at = format_datetime( + datetime.fromtimestamp(base + 30, timezone.utc), usegmt=True + ) + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + Response(status=503, headers={"Retry-After": retry_at}), + Response(status=200), + ) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT, timeout=120.0) + shutdown_event = self._mocked_shutdown_event() + shutdown_event.wait.return_value = False + exporter._client._shutdown_event = shutdown_event + + with patch( + "opentelemetry.exporter.otlp.common._http.time.time", + return_value=base, + ): + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + shutdown_event.wait.assert_called_once_with(30.0) + + @mocketize + def test_export_connection_error(self): + Entry.register( + Entry.POST, + _TEST_ENDPOINT, + urllib3.exceptions.ProtocolError("simulated reset"), + Response(status=200), + ) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT, timeout=5.0) + + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.SUCCESS) + self.assertEqual(len(Mocket.request_list()), 2) + + @mocketize + def test_export_after_shutdown(self): + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + exporter.shutdown() + + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.FAILURE) + self.assertIsNone(Mocket.last_request()) + + def test_shutdown_idempotent(self): + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + exporter.shutdown() + + with self.assertLogs(_LOGGER_NAME, level="WARNING"): + exporter.shutdown() + + # pylint: disable-next=no-self-use + def test_shutdown_closes_transport(self): + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + + with patch.object(exporter._client._transport, "close") as mock_close: + exporter.shutdown() + + mock_close.assert_called_once() + + @mocketize + def test_force_flush(self): + Entry.single_register(Entry.POST, _TEST_ENDPOINT, status=200) + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + + self.assertTrue(exporter.force_flush()) + exporter.export(_make_metrics_data()) + self.assertTrue(exporter.force_flush()) + + @mocketize + def test_export_encoding_failure(self): + exporter = OTLPMetricExporter(endpoint=_TEST_ENDPOINT) + + with ( + patch( + "opentelemetry.exporter.otlp.json.http.metric_exporter.encode_metrics", + side_effect=ValueError("boom"), + ), + self.assertLogs(_LOGGER_NAME, level="ERROR"), + ): + result = exporter.export(_make_metrics_data()) + + self.assertEqual(result, MetricExportResult.FAILURE) + self.assertIsNone(Mocket.last_request()) From e1360bfc77c33e1aed4885714ee9b635c9c87d3c Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 23:25:43 -0500 Subject: [PATCH 14/20] update docs --- docs/conf.py | 8 +++ docs/exporter/otlp/otlp.rst | 22 +++++++ .../README.rst | 25 ++++--- .../exporter/otlp/json/http/__init__.py | 65 +++++++++++++++++++ 4 files changed, 106 insertions(+), 14 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 01b386beacc..e3a2ebd69e9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -194,6 +194,14 @@ # ``from os import PathLike`` renders as the bare name ``PathLike`` in the # file exporter type hints, which sphinx cannot resolve to os.PathLike. ("py:class", "PathLike"), + # ``Compression``/``BaseHTTPTransport`` resolve to their private defining + # modules in the json-http exporter's type hints; those modules aren't + # documented on their own. + ("py:class", "opentelemetry.exporter.otlp.common._http.Compression"), + ( + "py:class", + "opentelemetry.exporter.http.transport._base.BaseHTTPTransport", + ), ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/exporter/otlp/otlp.rst b/docs/exporter/otlp/otlp.rst index 4402c9bc880..343ec153895 100644 --- a/docs/exporter/otlp/otlp.rst +++ b/docs/exporter/otlp/otlp.rst @@ -72,3 +72,25 @@ opentelemetry.exporter.otlp.json.file :members: :undoc-members: :show-inheritance: + +opentelemetry.exporter.otlp.json.http +--------------------------------------- + +.. automodule:: opentelemetry.exporter.otlp.json.http + :no-members: + :no-undoc-members: + +.. automodule:: opentelemetry.exporter.otlp.json.http.trace_exporter + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: opentelemetry.exporter.otlp.json.http.metric_exporter + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: opentelemetry.exporter.otlp.json.http._log_exporter + :members: + :undoc-members: + :show-inheritance: diff --git a/exporter/opentelemetry-exporter-otlp-json-http/README.rst b/exporter/opentelemetry-exporter-otlp-json-http/README.rst index 11c8dbef554..5c97f64075e 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/README.rst +++ b/exporter/opentelemetry-exporter-otlp-json-http/README.rst @@ -1,30 +1,27 @@ -OpenTelemetry JSON File Exporter -================================ +OpenTelemetry Collector JSON over HTTP 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/ +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-exporter-otlp-json-http.svg + :target: https://pypi.org/project/opentelemetry-exporter-otlp-json-http/ -This library 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. +This library allows to export data to the OpenTelemetry Collector using the +OpenTelemetry Protocol using OTLP JSON over HTTP. Installation ------------ :: - pip install opentelemetry-exporter-otlp-json-file + pip install opentelemetry-exporter-otlp-json-http References ---------- -* `OpenTelemetry Project `_ -* `OpenTelemetry Protocol File Exporter `_ -* `OTLP Specification `_ +* `OpenTelemetry Collector Exporter `_ +* `OpenTelemetry Collector `_ +* `OpenTelemetry `_ +* `OpenTelemetry Protocol Specification `_ * `OTLP JSON Encoding Specification `_ -* `JSON Lines `_ diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py index 5c41df76028..54101402e6b 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/__init__.py @@ -1,6 +1,71 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +""" +This library allows exporting OpenTelemetry telemetry to an OTLP collector +using the OTLP JSON over HTTP wire format. + +Usage +----- + +The **OTLP JSON HTTP Exporter** exports `OpenTelemetry`_ traces, metrics, +and logs to an `OTLP`_ collector via HTTP, using OTLP JSON encoding instead +of protobuf. + +Three exporters are provided: + +- :class:`~opentelemetry.exporter.otlp.json.http.trace_exporter.OTLPSpanExporter` - traces +- :class:`~opentelemetry.exporter.otlp.json.http.metric_exporter.OTLPMetricExporter` - metrics +- :class:`~opentelemetry.exporter.otlp.json.http._log_exporter.OTLPLogExporter` - logs + +You can configure each exporter with the following environment variables, +using the appropriate per-signal prefix (``TRACES``, ``METRICS``, or +``LOGS``) or the generic variant that applies to all three: + +- :envvar:`OTEL_EXPORTER_OTLP_ENDPOINT` +- :envvar:`OTEL_EXPORTER_OTLP_HEADERS` +- :envvar:`OTEL_EXPORTER_OTLP_TIMEOUT` +- :envvar:`OTEL_EXPORTER_OTLP_COMPRESSION` +- :envvar:`OTEL_EXPORTER_OTLP_CERTIFICATE` +- :envvar:`OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` +- :envvar:`OTEL_EXPORTER_OTLP_CLIENT_KEY` + +The metric exporter also supports the following environment variables: + +- :envvar:`OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` +- :envvar:`OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION` + +.. _OTLP: https://github.com/open-telemetry/opentelemetry-collector/ +.. _OpenTelemetry: https://github.com/open-telemetry/opentelemetry-python/ + +.. code:: python + + from opentelemetry import trace + from opentelemetry.exporter.otlp.json.http import OTLPSpanExporter + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + resource = Resource.create({ + "service.name": "service" + }) + + trace.set_tracer_provider(TracerProvider(resource=resource)) + tracer = trace.get_tracer(__name__) + + otlp_exporter = OTLPSpanExporter() + + span_processor = BatchSpanProcessor(otlp_exporter) + + trace.get_tracer_provider().add_span_processor(span_processor) + + with tracer.start_as_current_span("foo"): + print("Hello world!") + +API +--- +""" + from opentelemetry.exporter.otlp.json.http.metric_exporter import ( OTLPMetricExporter, ) From 0bab3ea88189dca4ac578ffcfe28b77b91054b8b Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 23:43:19 -0500 Subject: [PATCH 15/20] ignore tests for type checking --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 83c6fcfe34d..683881b2a99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,6 +152,7 @@ exclude = [ "exporter/opentelemetry-exporter-otlp-proto-grpc/tests", "exporter/opentelemetry-exporter-otlp-proto-http/tests", "exporter/opentelemetry-exporter-otlp-json-common/tests", + "exporter/opentelemetry-exporter-otlp-json-http/tests", "exporter/opentelemetry-exporter-otlp-common/tests", "exporter/opentelemetry-exporter-otlp-json-common/benchmarks", ] From 1a7f17cfb98b6f175276eb51e9cd40adf75b9dc3 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 1 Jul 2026 23:52:28 -0500 Subject: [PATCH 16/20] update internal helpers --- .../exporter/otlp/json/http/_internal.py | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py index 954c4888bac..1094313ab1e 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -87,9 +87,12 @@ def _resolve_timeout( def _resolve_compression(compression_env_var: str) -> Compression: val = ( - os.environ.get( - compression_env_var, - os.environ.get(OTEL_EXPORTER_OTLP_COMPRESSION, "none"), + ( + os.environ.get( + compression_env_var, + ) + or os.environ.get(OTEL_EXPORTER_OTLP_COMPRESSION) + or "none" ) .lower() .strip() @@ -103,7 +106,7 @@ def _resolve_compression(compression_env_var: str) -> Compression: def _build_transport( - certificate_file: str | None, + certificate_file: str | bool | None, client_key_file: str | None, client_certificate_file: str | None, certificate_env_var: str, @@ -111,17 +114,26 @@ def _build_transport( client_certificate_env_var: str, transport_factory: BaseHTTPTransportFactory = Urllib3HTTPTransport, ) -> BaseHTTPTransport: - verify: bool | str = certificate_file or os.environ.get( - certificate_env_var, - os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), + verify: bool | str = ( + certificate_file + or os.environ.get( + certificate_env_var, + ) + or os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True) ) - client_key_file = client_key_file or os.environ.get( - client_key_env_var, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY), + client_key_file = ( + client_key_file + or os.environ.get( + client_key_env_var, + ) + or os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_KEY) ) - client_certificate_file = client_certificate_file or os.environ.get( - client_certificate_env_var, - os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE), + client_certificate_file = ( + client_certificate_file + or os.environ.get( + client_certificate_env_var, + ) + or os.environ.get(OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE) ) return transport_factory( verify=verify, From 4413f7e8ff6791ec8034ff491d9a121da648950c Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Thu, 2 Jul 2026 16:03:29 -0500 Subject: [PATCH 17/20] resolve subtle bug --- .../exporter/otlp/json/http/_internal.py | 9 +-- .../tests/test_internal.py | 59 ++++++++++++++++++- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py index 1094313ab1e..8bf175ccbff 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -51,8 +51,8 @@ def _resolve_headers( headers_env_var: str, ) -> dict[str, str]: headers_ = { - "Content-Type": "application/json", - "User-Agent": "OTel-OTLP-JSON-Exporter-Python/" + __version__, + "content-type": "application/json", + "user-agent": "OTel-OTLP-JSON-Exporter-Python/" + __version__, } env_headers = parse_env_headers( os.environ.get(headers_env_var) @@ -61,7 +61,7 @@ def _resolve_headers( ) headers_.update(env_headers) if headers: - headers_.update(headers) + headers_.update({key.lower(): value for key, value in headers.items()}) return headers_ @@ -119,7 +119,8 @@ def _build_transport( or os.environ.get( certificate_env_var, ) - or os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True) + or os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE) + or True ) client_key_file = ( client_key_file diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py index b5fad776c5a..658004be44f 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py @@ -37,7 +37,7 @@ ) _USER_AGENT = "OTel-OTLP-JSON-Exporter-Python/" + __version__ -_BASE_HEADERS = {"Content-Type": "application/json", "User-Agent": _USER_AGENT} +_BASE_HEADERS = {"content-type": "application/json", "user-agent": _USER_AGENT} class TestResolveInternal(unittest.TestCase): @@ -139,11 +139,30 @@ def test_resolve_headers(self): {OTEL_EXPORTER_OTLP_HEADERS: "api-key=from-env"}, {"api-key": "explicit", "Content-Type": "text/plain"}, { - "Content-Type": "text/plain", - "User-Agent": _USER_AGENT, + "content-type": "text/plain", + "user-agent": _USER_AGENT, "api-key": "explicit", }, ), + # env override of a default header must replace it + ( + "env_overrides_default_header_case_insensitively", + {OTEL_EXPORTER_OTLP_HEADERS: "user-agent=custom-agent"}, + None, + { + "content-type": "application/json", + "user-agent": "custom-agent", + }, + ), + ( + "explicit_arg_overrides_default_header_case_insensitively", + {}, + {"User-Agent": "explicit-agent"}, + { + "content-type": "application/json", + "user-agent": "explicit-agent", + }, + ), ] for label, env, headers_arg, expected in cases: with self.subTest(label), patch.dict(os.environ, env, clear=True): @@ -249,6 +268,16 @@ def test_resolve_compression(self): Compression.GZIP, False, ), + ( + "empty_per_signal_falls_back_to_general", + { + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "", + OTEL_EXPORTER_OTLP_COMPRESSION: "gzip", + }, + OTEL_EXPORTER_OTLP_TRACES_COMPRESSION, + Compression.GZIP, + False, + ), ( "default", {}, @@ -331,6 +360,30 @@ def test_build_transport(self): "general-cert.pem", ("general-cert2.pem", "general-key.pem"), ), + ( + "empty_per_signal_certificate_falls_back_to_general", + { + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE: "", + OTEL_EXPORTER_OTLP_CERTIFICATE: "general-cert.pem", + }, + None, + None, + None, + "general-cert.pem", + None, + ), + ( + "empty_certificate_at_every_level_falls_back_to_default_true", + { + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE: "", + OTEL_EXPORTER_OTLP_CERTIFICATE: "", + }, + None, + None, + None, + True, + None, + ), ( "defaults_verify_true_no_cert", {}, From e636f6242d74d6c92cf482b6bb557925d53791c8 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Thu, 2 Jul 2026 16:10:39 -0500 Subject: [PATCH 18/20] update package imports --- .../opentelemetry/exporter/otlp/common/__init__.py | 4 ---- .../exporter/otlp/common/{_http.py => http.py} | 14 +++++++------- .../tests/test_http_client.py | 6 +++--- .../exporter/otlp/json/http/_internal.py | 2 +- .../exporter/otlp/json/http/_log_exporter.py | 8 +++++--- .../exporter/otlp/json/http/metric_exporter.py | 8 +++++--- .../exporter/otlp/json/http/trace_exporter.py | 8 +++++--- .../tests/__init__.py | 2 +- .../tests/test_internal.py | 2 +- .../tests/test_log_exporter.py | 4 ++-- .../tests/test_metric_exporter.py | 4 ++-- .../tests/test_trace_exporter.py | 4 ++-- 12 files changed, 34 insertions(+), 32 deletions(-) rename exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/{_http.py => http.py} (95%) diff --git a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py index be3d705c562..e57cf4aba95 100644 --- a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/__init__.py @@ -1,6 +1,2 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 - -from opentelemetry.exporter.otlp.common._http import Compression - -__all__ = ["Compression"] diff --git a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/_http.py b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py similarity index 95% rename from exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/_http.py rename to exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py index 3680be73ba6..0163d2ee743 100644 --- a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/_http.py +++ b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py @@ -97,7 +97,7 @@ def from_str(value: str) -> Compression: @dataclass(slots=True, frozen=True) -class ExportResult: +class _ExportResult: """Outcome of an OTLP export attempt, including retry exhaustion.""" success: bool @@ -106,7 +106,7 @@ class ExportResult: error: Exception | None -class OTLPHTTPClient: +class _OTLPHTTPClient: """Sends serialized OTLP payloads over HTTP with retry logic. Compression, backoff, and connection-error recovery are handled internally. @@ -178,7 +178,7 @@ def _submit(self, data: bytes, timeout: float) -> BaseHTTPResult: ) return result - def export(self, data: bytes) -> ExportResult: + def export(self, data: bytes) -> _ExportResult: """Export a serialized payload, retrying on transient failures. :param data: Serialized bytes to send. @@ -204,7 +204,7 @@ def export(self, data: bytes) -> ExportResult: status_code = result.status_code reason = result.reason if status_code is not None and 200 <= status_code < 400: - return ExportResult(True, status_code, reason, None) + return _ExportResult(True, status_code, reason, None) export_error = result.error retryable = ( _is_retryable(status_code) @@ -226,7 +226,7 @@ def export(self, data: bytes) -> ExportResult: status_code, reason or export_error or "unknown", ) - return ExportResult(False, status_code, reason, export_error) + return _ExportResult(False, status_code, reason, export_error) if ( retry + 1 == _MAX_RETRIES @@ -238,7 +238,7 @@ def export(self, data: bytes) -> ExportResult: "max retries or shutdown.", self._kind, ) - return ExportResult(False, status_code, reason, export_error) + return _ExportResult(False, status_code, reason, export_error) self._logger.warning( "Transient error %s encountered while exporting %s batch, retrying in %.2fs.", @@ -251,7 +251,7 @@ def export(self, data: bytes) -> ExportResult: self._logger.warning("Shutdown in progress, aborting retry.") break - return ExportResult(False, None, None, None) + return _ExportResult(False, None, None, None) def shutdown(self) -> None: """Shutdown the client.""" diff --git a/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py b/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py index e7a7e420da5..f7200024171 100644 --- a/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py +++ b/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py @@ -21,10 +21,10 @@ ) # pylint: disable-next=import-error -from opentelemetry.exporter.otlp.common._http import ( +from opentelemetry.exporter.otlp.common.http import ( Compression, - OTLPHTTPClient, _extract_retry_after, + _OTLPHTTPClient, ) @@ -115,7 +115,7 @@ def _client( compression=Compression.NONE, jitter=0.0, ): - return OTLPHTTPClient( + return _OTLPHTTPClient( transport=transport, endpoint="http://example.test/v1/traces", timeout=timeout, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py index 8bf175ccbff..d0d7b617630 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_internal.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Literal from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport -from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common.http import Compression from opentelemetry.exporter.otlp.json.http.version import __version__ from opentelemetry.sdk.environment_variables import ( OTEL_EXPORTER_OTLP_CERTIFICATE, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py index 066e3cd835b..0fb2a3b9d6b 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/_log_exporter.py @@ -6,8 +6,10 @@ from typing import overload from opentelemetry.exporter.http.transport._base import BaseHTTPTransport -from opentelemetry.exporter.otlp.common import Compression -from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient +from opentelemetry.exporter.otlp.common.http import ( + Compression, + _OTLPHTTPClient, +) from opentelemetry.exporter.otlp.json.common._internal._log_encoder import ( encode_logs, ) @@ -87,7 +89,7 @@ def __init__( OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY, OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, ) - self._client = OTLPHTTPClient( + self._client = _OTLPHTTPClient( transport=transport, endpoint=endpoint or _resolve_endpoint( diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py index 5e65cff5dcd..ff7368f3595 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/metric_exporter.py @@ -6,12 +6,14 @@ from typing import overload from opentelemetry.exporter.http.transport._base import BaseHTTPTransport -from opentelemetry.exporter.otlp.common import Compression from opentelemetry.exporter.otlp.common._aggregation import ( _get_aggregation, _get_temporality, ) -from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient +from opentelemetry.exporter.otlp.common.http import ( + Compression, + _OTLPHTTPClient, +) from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( encode_metrics, split_metrics_data, @@ -109,7 +111,7 @@ def __init__( OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY, OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, ) - self._client = OTLPHTTPClient( + self._client = _OTLPHTTPClient( transport=transport, endpoint=endpoint or _resolve_endpoint( diff --git a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py index f0ca95894fa..170bd8acaee 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/src/opentelemetry/exporter/otlp/json/http/trace_exporter.py @@ -6,8 +6,10 @@ from typing import overload from opentelemetry.exporter.http.transport._base import BaseHTTPTransport -from opentelemetry.exporter.otlp.common import Compression -from opentelemetry.exporter.otlp.common._http import OTLPHTTPClient +from opentelemetry.exporter.otlp.common.http import ( + Compression, + _OTLPHTTPClient, +) from opentelemetry.exporter.otlp.json.common._internal.trace_encoder import ( encode_spans, ) @@ -82,7 +84,7 @@ def __init__( OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, ) - self._client = OTLPHTTPClient( + self._client = _OTLPHTTPClient( transport=transport, endpoint=endpoint or _resolve_endpoint( diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py index e05ad351f69..02ff6ea5b06 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/__init__.py @@ -26,7 +26,7 @@ def _wait(duration: float) -> bool: shutdown_event.wait.side_effect = _wait with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", side_effect=get_time, ): yield advance diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py index 658004be44f..a842dad9e70 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_internal.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch from opentelemetry.exporter.http.transport._urllib3 import Urllib3HTTPTransport -from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common.http import Compression from opentelemetry.exporter.otlp.json.http._internal import ( _DEFAULT_TIMEOUT, _build_transport, diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py index ec343195d00..3f3713f043a 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_log_exporter.py @@ -21,7 +21,7 @@ from opentelemetry.exporter.http.transport._urllib3 import ( Urllib3HTTPTransport, ) -from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common.http import Compression from opentelemetry.exporter.otlp.json.common._log_encoder import encode_logs from opentelemetry.exporter.otlp.json.http._internal import _build_transport from opentelemetry.exporter.otlp.json.http._log_exporter import ( @@ -481,7 +481,7 @@ def test_export_retry_after_header_http_date(self): exporter._client._shutdown_event = shutdown_event with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", return_value=base, ): result = exporter.export(self._make_log()) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py index d09f4f06294..55a7db71e93 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_metric_exporter.py @@ -21,7 +21,7 @@ from opentelemetry.exporter.http.transport._urllib3 import ( Urllib3HTTPTransport, ) -from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common.http import Compression from opentelemetry.exporter.otlp.json.common._internal.metrics_encoder import ( split_metrics_data, ) @@ -560,7 +560,7 @@ def test_export_retry_after_header_http_date(self): exporter._client._shutdown_event = shutdown_event with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", return_value=base, ): result = exporter.export(_make_metrics_data()) diff --git a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py index 13bba3153be..77688a0ff46 100644 --- a/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-json-http/tests/test_trace_exporter.py @@ -20,7 +20,7 @@ from opentelemetry.exporter.http.transport._urllib3 import ( Urllib3HTTPTransport, ) -from opentelemetry.exporter.otlp.common import Compression +from opentelemetry.exporter.otlp.common.http import Compression from opentelemetry.exporter.otlp.json.common.trace_encoder import encode_spans from opentelemetry.exporter.otlp.json.http._internal import _build_transport from opentelemetry.exporter.otlp.json.http.trace_exporter import ( @@ -471,7 +471,7 @@ def test_export_retry_after_header_http_date(self): exporter._client._shutdown_event = shutdown_event with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", return_value=base, ): result = exporter.export(self._make_span()) From 319213b8361227749441e8be95c72bbf6dbba3bf Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Thu, 2 Jul 2026 16:13:16 -0500 Subject: [PATCH 19/20] fix docs build --- docs/conf.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e3a2ebd69e9..543aa13d3fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -194,10 +194,7 @@ # ``from os import PathLike`` renders as the bare name ``PathLike`` in the # file exporter type hints, which sphinx cannot resolve to os.PathLike. ("py:class", "PathLike"), - # ``Compression``/``BaseHTTPTransport`` resolve to their private defining - # modules in the json-http exporter's type hints; those modules aren't - # documented on their own. - ("py:class", "opentelemetry.exporter.otlp.common._http.Compression"), + ("py:class", "opentelemetry.exporter.otlp.common.http.Compression"), ( "py:class", "opentelemetry.exporter.http.transport._base.BaseHTTPTransport", From e1a89a46dba83d1a4fa37224672f43dc7179fcbd Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Thu, 2 Jul 2026 16:23:13 -0500 Subject: [PATCH 20/20] fix failing tests --- .../tests/test_http_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py b/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py index f7200024171..638636c1bcc 100644 --- a/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py +++ b/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py @@ -49,7 +49,7 @@ def _wait(duration: float) -> bool: shutdown_event.wait.side_effect = _wait with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", side_effect=get_time, ): yield advance @@ -147,7 +147,7 @@ def test_export_success_status_codes(self): self.assertIsNone(result.error) @patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", side_effect=(100.0, 100.0, 100.0), ) def test_export_request_arguments(self, mock_time): @@ -479,7 +479,7 @@ def test_export_retry_after_http_date(self): client._shutdown_event = shutdown_event with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", return_value=base, ): result = client.export(b"payload") @@ -509,7 +509,7 @@ def test_export_retry_after_http_date_in_past(self): client._shutdown_event = shutdown_event with patch( - "opentelemetry.exporter.otlp.common._http.time.time", + "opentelemetry.exporter.otlp.common.http.time.time", return_value=base, ): result = client.export(b"payload")