mirror of https://github.com/razor-ai/soup.git
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""OpenTelemetry request tracing for soup serve (v0.30.0).
|
|
|
|
All imports are lazy; tracing is a no-op when the SDK is missing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import logging
|
|
from typing import Any, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
|
|
|
|
def is_otel_available() -> bool:
|
|
try:
|
|
import opentelemetry # noqa: F401
|
|
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def _is_private_ip(host: str) -> bool:
|
|
"""True if host resolves to a private, link-local, or unspecified IP."""
|
|
try:
|
|
addr = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return False
|
|
return (
|
|
addr.is_private
|
|
or addr.is_link_local
|
|
or addr.is_unspecified
|
|
or addr.is_reserved
|
|
or addr.is_multicast
|
|
)
|
|
|
|
|
|
def validate_otlp_endpoint(endpoint: str) -> str:
|
|
"""SSRF-hardened OTLP endpoint validation.
|
|
|
|
Rules (mirrors utils/hf.py):
|
|
- scheme must be http/https
|
|
- plain http only permitted for loopback hosts
|
|
- null byte rejected
|
|
- RFC1918 / link-local / cloud-metadata IPs rejected (defence-in-depth
|
|
so an operator can't point the exporter at 169.254.169.254 etc.)
|
|
"""
|
|
if not isinstance(endpoint, str):
|
|
raise ValueError("OTLP endpoint must be a string")
|
|
if "\x00" in endpoint:
|
|
raise ValueError("OTLP endpoint contains null byte")
|
|
|
|
parsed = urlparse(endpoint)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError(
|
|
f"OTLP endpoint scheme must be http/https, got {parsed.scheme!r}"
|
|
)
|
|
host = (parsed.hostname or "").lower()
|
|
if not host:
|
|
raise ValueError("OTLP endpoint missing host")
|
|
# Explicit unspecified host 0.0.0.0 always rejected (valid for bind, not connect)
|
|
if host == "0.0.0.0":
|
|
raise ValueError("OTLP endpoint host 0.0.0.0 is not a valid destination")
|
|
if parsed.scheme == "http" and host not in _LOOPBACK_HOSTS:
|
|
raise ValueError(
|
|
f"plain HTTP is only allowed for loopback hosts; use HTTPS for {host}"
|
|
)
|
|
if host not in _LOOPBACK_HOSTS and _is_private_ip(host):
|
|
raise ValueError(
|
|
f"OTLP endpoint host {host} is a private / link-local IP; "
|
|
"use a public endpoint or a loopback host"
|
|
)
|
|
return endpoint
|
|
|
|
|
|
def build_tracer(
|
|
enabled: bool,
|
|
endpoint: Optional[str] = None,
|
|
service_name: str = "soup-serve",
|
|
) -> Optional[Any]:
|
|
"""Build an OpenTelemetry tracer provider.
|
|
|
|
Returns None when disabled, when the SDK is missing, or when initialisation
|
|
fails — callers should fall through without crashing. The server still
|
|
accepts requests; it just doesn't emit spans.
|
|
"""
|
|
if not enabled:
|
|
return None
|
|
if not is_otel_available():
|
|
logger.warning(
|
|
"--trace was requested but opentelemetry-sdk is not installed; "
|
|
"tracing disabled. Install with: pip install opentelemetry-sdk "
|
|
"opentelemetry-exporter-otlp"
|
|
)
|
|
return None
|
|
if endpoint is not None:
|
|
validate_otlp_endpoint(endpoint)
|
|
|
|
try:
|
|
from opentelemetry import trace # type: ignore[import-not-found]
|
|
from opentelemetry.sdk.resources import Resource # type: ignore[import-not-found]
|
|
from opentelemetry.sdk.trace import TracerProvider # type: ignore[import-not-found]
|
|
from opentelemetry.sdk.trace.export import ( # type: ignore[import-not-found]
|
|
BatchSpanProcessor,
|
|
)
|
|
|
|
resource = Resource.create({"service.name": service_name})
|
|
provider = TracerProvider(resource=resource)
|
|
|
|
# Exporter is optional — user may rely on their own instrumentation.
|
|
if endpoint:
|
|
try:
|
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-not-found]
|
|
OTLPSpanExporter,
|
|
)
|
|
|
|
provider.add_span_processor(
|
|
BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
|
|
)
|
|
except ImportError:
|
|
logger.warning(
|
|
"opentelemetry-exporter-otlp not installed; spans "
|
|
"will not be exported to %s",
|
|
endpoint,
|
|
)
|
|
|
|
# Idempotent: only install our provider if no user-supplied one is
|
|
# already set. Otherwise we'd silently drop the operator's
|
|
# instrumentation (and any unflushed spans).
|
|
existing = trace.get_tracer_provider()
|
|
existing_type = type(existing).__name__
|
|
if existing_type in ("ProxyTracerProvider", "NoOpTracerProvider"):
|
|
trace.set_tracer_provider(provider)
|
|
else:
|
|
logger.info(
|
|
"TracerProvider already set (%s); reusing without override.",
|
|
existing_type,
|
|
)
|
|
return trace.get_tracer("soup.serve")
|
|
except Exception: # pragma: no cover - defensive
|
|
logger.exception("Failed to initialise OpenTelemetry tracer")
|
|
return None
|