Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5273.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-exporter-otlp-proto-http`: auto-append signal path when a base URL (no path or path `/`) is passed as `endpoint` to HTTP OTLP exporters
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from os import environ
from typing import Literal
from urllib.parse import urlparse, urlunparse

import requests

Expand All @@ -12,6 +13,24 @@
from opentelemetry.util._importlib_metadata import entry_points


def _resolve_endpoint_to_signal(endpoint: str, signal_path: str) -> str:
"""Append signal_path to endpoint if it has no signal-specific path.

Uses proper URL manipulation so query strings and fragments are preserved.
If the endpoint already has a path other than '/', or cannot be parsed as
a URL, it is returned unchanged.
"""
try:
parsed = urlparse(endpoint)
except ValueError:
return endpoint
path = getattr(parsed, "path", "")
if not path or path == "/":
base = path.rstrip("/")
return urlunparse(parsed._replace(path=f"{base}/{signal_path}"))
return endpoint


def _is_retryable(resp: requests.Response) -> bool:
if resp.status_code == 408:
return True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_resolve_endpoint_to_signal,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk._logs import ReadableLogRecord
Expand Down Expand Up @@ -88,12 +89,17 @@ def __init__(
meter_provider: MeterProvider | None = None,
):
self._shutdown_is_occuring = threading.Event()
self._endpoint = endpoint or environ.get(
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
_append_logs_path(
environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT)
),
)
if endpoint is not None:
self._endpoint = _resolve_endpoint_to_signal(
endpoint, DEFAULT_LOGS_EXPORT_PATH
)
else:
self._endpoint = environ.get(
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
_append_logs_path(
environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT)
),
)
# Keeping these as instance variables because they are used in tests
self._certificate_file = certificate_file or environ.get(
OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_resolve_endpoint_to_signal,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( # noqa: F401
Expand Down Expand Up @@ -147,12 +148,17 @@ def __init__(
If it is set and the number of data points exceeds the max, the request will be split.
"""
self._shutdown_in_progress = threading.Event()
self._endpoint = endpoint or environ.get(
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
_append_metrics_path(
environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT)
),
)
if endpoint is not None:
self._endpoint = _resolve_endpoint_to_signal(
endpoint, DEFAULT_METRICS_EXPORT_PATH
)
else:
self._endpoint = environ.get(
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
_append_metrics_path(
environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT)
),
)
self._certificate_file = certificate_file or environ.get(
OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE,
environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from opentelemetry.exporter.otlp.proto.http._common import (
_is_retryable,
_load_session_from_envvar,
_resolve_endpoint_to_signal,
)
from opentelemetry.metrics import MeterProvider
from opentelemetry.sdk.environment_variables import (
Expand Down Expand Up @@ -84,12 +85,17 @@ def __init__(
meter_provider: MeterProvider | None = None,
):
self._shutdown_in_progress = threading.Event()
self._endpoint = endpoint or environ.get(
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
_append_trace_path(
environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT)
),
)
if endpoint is not None:
self._endpoint = _resolve_endpoint_to_signal(
endpoint, DEFAULT_TRACES_EXPORT_PATH
)
else:
self._endpoint = environ.get(
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
_append_trace_path(
environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT)
),
)
self._certificate_file = certificate_file or environ.get(
OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE,
environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,30 @@ def test_exporter_env_endpoint_with_slash(self):
OS_ENV_ENDPOINT + f"/{DEFAULT_METRICS_EXPORT_PATH}",
)

def test_endpoint_base_url_no_path(self):
exporter = OTLPMetricExporter(endpoint="http://collector:4318")
self.assertEqual(
exporter._endpoint, "http://collector:4318/v1/metrics"
)

def test_endpoint_base_url_trailing_slash(self):
exporter = OTLPMetricExporter(endpoint="http://collector:4318/")
self.assertEqual(
exporter._endpoint, "http://collector:4318/v1/metrics"
)

def test_endpoint_full_url_unchanged(self):
exporter = OTLPMetricExporter(
endpoint="http://collector:4318/v1/metrics"
)
self.assertEqual(
exporter._endpoint, "http://collector:4318/v1/metrics"
)

def test_endpoint_custom_path_unchanged(self):
exporter = OTLPMetricExporter(endpoint="http://collector:4318/custom")
self.assertEqual(exporter._endpoint, "http://collector:4318/custom")

@patch.dict(
"os.environ",
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

from opentelemetry._logs import LogRecord, SeverityNumber
from opentelemetry.exporter.otlp.proto.http import Compression
from opentelemetry.exporter.otlp.proto.http._common import (
_resolve_endpoint_to_signal,
)
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
DEFAULT_COMPRESSION,
DEFAULT_ENDPOINT,
Expand Down Expand Up @@ -259,6 +262,38 @@ def test_exporter_env(self):
)
self.assertIsInstance(exporter._session, requests.Session)

def test_endpoint_base_url_no_path(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add tests for some malformed, invalid urls

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments fixed

exporter = OTLPLogExporter(endpoint="http://collector:4318")
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs")

def test_endpoint_base_url_trailing_slash(self):
exporter = OTLPLogExporter(endpoint="http://collector:4318/")
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs")

def test_endpoint_full_url_unchanged(self):
exporter = OTLPLogExporter(endpoint="http://collector:4318/v1/logs")
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs")

def test_endpoint_custom_path_unchanged(self):
exporter = OTLPLogExporter(endpoint="http://collector:4318/custom")
self.assertEqual(exporter._endpoint, "http://collector:4318/custom")

def test_endpoint_unparsable_url_unchanged(self):
# Unclosed IPv6 bracket makes urlparse raise ValueError; the
# helper must pass the endpoint through unchanged.
self.assertEqual(
_resolve_endpoint_to_signal(
"http://[invalid:4318", DEFAULT_LOGS_EXPORT_PATH
),
"http://[invalid:4318",
)

def test_endpoint_schemeless_host_port_unchanged(self):
# Without a scheme, urlparse reads "collector" as the scheme and
# "4318" as the path, so the endpoint is left untouched.
exporter = OTLPLogExporter(endpoint="collector:4318")
self.assertEqual(exporter._endpoint, "collector:4318")

@staticmethod
def export_log_and_deserialize(log):
with patch("requests.Session.post") as mock_post:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from requests.models import Response

from opentelemetry.exporter.otlp.proto.http import Compression
from opentelemetry.exporter.otlp.proto.http._common import (
_resolve_endpoint_to_signal,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_COMPRESSION,
DEFAULT_ENDPOINT,
Expand Down Expand Up @@ -247,6 +250,47 @@ def test_exporter_env_endpoint_with_slash(self):
OS_ENV_ENDPOINT + f"/{DEFAULT_TRACES_EXPORT_PATH}",
)

def test_endpoint_base_url_no_path(self):
exporter = OTLPSpanExporter(endpoint="http://collector:4318")
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/traces")

def test_endpoint_base_url_trailing_slash(self):
exporter = OTLPSpanExporter(endpoint="http://collector:4318/")
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/traces")

def test_endpoint_full_url_unchanged(self):
exporter = OTLPSpanExporter(endpoint="http://collector:4318/v1/traces")
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/traces")

def test_endpoint_custom_path_unchanged(self):
exporter = OTLPSpanExporter(endpoint="http://collector:4318/custom")
self.assertEqual(exporter._endpoint, "http://collector:4318/custom")

def test_endpoint_base_url_with_query_string(self):
exporter = OTLPSpanExporter(
endpoint="http://collector:4318?tenant=acme"
)
self.assertEqual(
exporter._endpoint,
"http://collector:4318/v1/traces?tenant=acme",
)

def test_endpoint_unparsable_url_unchanged(self):
# Unclosed IPv6 bracket makes urlparse raise ValueError; the
# helper must pass the endpoint through unchanged.
self.assertEqual(
_resolve_endpoint_to_signal(
"http://[invalid:4318", DEFAULT_TRACES_EXPORT_PATH
),
"http://[invalid:4318",
)

def test_endpoint_schemeless_host_port_unchanged(self):
# Without a scheme, urlparse reads "collector" as the scheme and
# "4318" as the path, so the endpoint is left untouched.
exporter = OTLPSpanExporter(endpoint="collector:4318")
self.assertEqual(exporter._endpoint, "collector:4318")

@patch.dict(
"os.environ",
{
Expand Down