This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new f8baa0a8a05 fix(metrics/otel): bracket IPv6 host literals in exporter 
endpoint URL (#66813)
f8baa0a8a05 is described below

commit f8baa0a8a05713efd7bbb886563a7a1b50174d1c
Author: Stefan Wang <[email protected]>
AuthorDate: Tue May 12 14:59:27 2026 -0700

    fix(metrics/otel): bracket IPv6 host literals in exporter endpoint URL 
(#66813)
    
    get_otel_data_exporter() builds the OTLP exporter endpoint as a raw
    f-string. When host is an IPv6 literal (e.g. ::1, 2001:db8::1) the
    resulting URL is invalid per RFC 3986 section 3.2.2 — the v6 host needs
    to be enclosed in [...] so the colon separators don't conflict with the
    host:port delimiter.
    
    Add _format_url_host() that brackets bare v6 literals and leaves
    hostnames, IPv4 literals, and already-bracketed v6 strings unchanged.
    
    Closes #66811
    
    Signed-off-by: 1fanwang <[email protected]>
---
 .../src/airflow_shared/observability/common.py     | 17 +++++++++++-
 .../observability/metrics/test_otel_logger.py      | 32 ++++++++++++++++++++++
 2 files changed, 48 insertions(+), 1 deletion(-)

diff --git a/shared/observability/src/airflow_shared/observability/common.py 
b/shared/observability/src/airflow_shared/observability/common.py
index c611782daa0..e912664b359 100644
--- a/shared/observability/src/airflow_shared/observability/common.py
+++ b/shared/observability/src/airflow_shared/observability/common.py
@@ -30,6 +30,21 @@ if TYPE_CHECKING:
 log = structlog.getLogger(__name__)
 
 
+def _format_url_host(host: str | None) -> str | None:
+    """
+    Bracket IPv6 host literals for embedding in a URL authority.
+
+    Per RFC 3986 §3.2.2, IPv6 hosts in a URI must be enclosed in square
+    brackets so the ``:`` separators do not conflict with the ``host:port``
+    delimiter. Hostnames, IPv4 literals, and already-bracketed v6 literals
+    are returned unchanged. ``None`` is passed through so existing
+    error-logging paths keep their shape.
+    """
+    if host is not None and ":" in host and not host.startswith("["):
+        return f"[{host}]"
+    return host
+
+
 def get_otel_data_exporter(
     *,
     otel_env_config: OtelEnvConfig,
@@ -106,7 +121,7 @@ def get_otel_data_exporter(
 
         endpoint_suffix = "traces" if otel_env_config.data_type == 
OtelDataType.TRACES else "metrics"
 
-        endpoint_str = f"{protocol}://{host}:{port}/v1/{endpoint_suffix}"
+        endpoint_str = 
f"{protocol}://{_format_url_host(host)}:{port}/v1/{endpoint_suffix}"
         if otel_env_config.data_type == OtelDataType.TRACES:
             exporter = OTLPSpanExporter(endpoint=endpoint_str)
         else:
diff --git 
a/shared/observability/tests/observability/metrics/test_otel_logger.py 
b/shared/observability/tests/observability/metrics/test_otel_logger.py
index 4bc889d4f13..f4c4cd369bf 100644
--- a/shared/observability/tests/observability/metrics/test_otel_logger.py
+++ b/shared/observability/tests/observability/metrics/test_otel_logger.py
@@ -394,6 +394,38 @@ class TestOtelMetrics:
                 "grpc",
                 id="type_specific_vars_take_precedence",
             ),
+            pytest.param(
+                {},
+                "::1",
+                "4318",
+                "http://[::1]:4318/v1/metrics";,
+                "http",
+                id="airflow_config_ipv6_loopback_is_bracketed",
+            ),
+            pytest.param(
+                {},
+                "2001:db8::1",
+                "4318",
+                "http://[2001:db8::1]:4318/v1/metrics";,
+                "http",
+                id="airflow_config_ipv6_literal_is_bracketed",
+            ),
+            pytest.param(
+                {},
+                "[::1]",
+                "4318",
+                "http://[::1]:4318/v1/metrics";,
+                "http",
+                id="airflow_config_already_bracketed_ipv6_is_preserved",
+            ),
+            pytest.param(
+                {},
+                "10.0.0.1",
+                "4318",
+                "http://10.0.0.1:4318/v1/metrics";,
+                "http",
+                id="airflow_config_ipv4_literal_passes_through_unchanged",
+            ),
         ],
     )
     def test_config_priorities(

Reply via email to