"""Thin Kayba-branded wrapper around MLflow tracing. All public symbols re-export MLflow functionality so that users never need to ``import mlflow`` directly. The :func:`configure` helper sets the MLflow tracking URI and auth to point at the Kayba backend. """ from __future__ import annotations import functools import os import re from contextlib import contextmanager from typing import Any, Callable, Generator, TypeVar, overload _TRACING_INSTALL_HINT = ( "Tracing requires mlflow: pip install kayba-tracing" ) try: import mlflow import mlflow.tracing # noqa: F401 — ensure tracing sub-module is loaded except ImportError as exc: raise ImportError(_TRACING_INSTALL_HINT) from exc DEFAULT_BASE_URL = "https://use.kayba.ai" # Module-level state set by configure() / set_folder(). _folder: str | None = None _MAX_FOLDER_LENGTH = 256 _SAFE_FOLDER_RE = re.compile(r"[^a-zA-Z0-9 _\-/.]") def _sanitize_folder(name: str) -> str: """Sanitize a folder name to prevent injection attacks. Strips control characters, HTML tags, and characters outside an allowlist. Truncates to ``_MAX_FOLDER_LENGTH``. """ # Strip HTML tags. clean = re.sub(r"<[^>]*>", "", name) # Remove anything outside the safe set. clean = _SAFE_FOLDER_RE.sub("", clean) return clean.strip()[:_MAX_FOLDER_LENGTH] _P = TypeVar("_P") _R = TypeVar("_R") def configure( *, api_key: str | None = None, base_url: str | None = None, experiment: str | None = None, folder: str | None = None, ) -> None: """Configure Kayba tracing. Sets the MLflow tracking URI and authentication so that all subsequent ``@trace`` / ``start_span`` calls export to Kayba. Args: api_key: Kayba API key. Falls back to ``KAYBA_API_KEY`` env var. base_url: Kayba API base URL. Falls back to ``KAYBA_API_URL`` env var, then to ``https://use.kayba.ai``. experiment: Alias for ``folder``. If both are provided, ``folder`` takes precedence. folder: Optional folder name. Traces will be filed into this folder in the Kayba dashboard. """ global _folder resolved_key = api_key or os.environ.get("KAYBA_API_KEY", "") if not resolved_key: raise ValueError( "No API key provided. Pass api_key= or set the KAYBA_API_KEY " "environment variable." ) resolved_url = base_url or os.environ.get("KAYBA_API_URL") or DEFAULT_BASE_URL # Strip trailing slash, then append the MLflow-compatible mount path. tracking_uri = resolved_url.rstrip("/") + "/api/mlflow" # Configure MLflow under the hood. os.environ["MLFLOW_TRACKING_TOKEN"] = resolved_key mlflow.set_tracking_uri(tracking_uri) resolved_folder = folder or experiment _folder = _sanitize_folder(resolved_folder) or None if resolved_folder else None def set_folder(folder: str | None) -> None: """Change the target folder for subsequent traces. Args: folder: Folder name, or ``None`` to clear (traces go to Unfiled). """ global _folder _folder = _sanitize_folder(folder) or None if folder else None def get_folder() -> str | None: """Return the currently configured folder, or ``None``.""" return _folder # --------------------------------------------------------------------------- # Wrapped MLflow tracing primitives that inject the folder tag # --------------------------------------------------------------------------- def _inject_folder_tag() -> None: """Inject ``kayba.folder`` tag into the active trace if a folder is set.""" if _folder is not None: mlflow.update_current_trace(tags={"kayba.folder": _folder}) @overload def trace(func: Callable[..., _R]) -> Callable[..., _R]: ... @overload def trace( func: None = None, *, name: str | None = None, span_type: str = ..., attributes: dict[str, Any] | None = None, ) -> Callable[[Callable[..., _R]], Callable[..., _R]]: ... def trace( func: Callable[..., Any] | None = None, *, name: str | None = None, span_type: str = "UNKNOWN", attributes: dict[str, Any] | None = None, ) -> Any: """Decorator that creates a trace span for the decorated function. Works identically to ``mlflow.trace`` but automatically tags the trace with the configured Kayba folder. """ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: # Wrap the original function so the folder tag is injected # *inside* the trace context (before MLflow closes it). @functools.wraps(fn) def fn_with_tag(*args: Any, **kwargs: Any) -> Any: result = fn(*args, **kwargs) _inject_folder_tag() return result # Let MLflow handle the actual tracing. mlflow_kwargs: dict[str, Any] = {} if name is not None: mlflow_kwargs["name"] = name if span_type != "UNKNOWN": mlflow_kwargs["span_type"] = span_type if attributes is not None: mlflow_kwargs["attributes"] = attributes if mlflow_kwargs: traced = mlflow.trace(**mlflow_kwargs)(fn_with_tag) else: traced = mlflow.trace(fn_with_tag) @functools.wraps(fn) def wrapper(*args: Any, **kwargs: Any) -> Any: return traced(*args, **kwargs) return wrapper if func is not None: # Called as @trace without parentheses. return decorator(func) return decorator @contextmanager def start_span( name: str = "span", span_type: str | None = "UNKNOWN", attributes: dict[str, Any] | None = None, ) -> Generator[Any, None, None]: """Context manager that creates a child span. Works identically to ``mlflow.start_span`` but automatically tags the trace with the configured Kayba folder when used as a root span. """ with mlflow.start_span( name=name, span_type=span_type, attributes=attributes ) as span: yield span # Inject folder tag while the trace context is still open. _inject_folder_tag() # --------------------------------------------------------------------------- # Utility functions # --------------------------------------------------------------------------- def enable() -> None: """Enable Kayba tracing (enabled by default after :func:`configure`).""" mlflow.tracing.enable() def disable() -> None: """Disable Kayba tracing without removing the configuration.""" mlflow.tracing.disable() def get_trace(trace_id: str) -> Any: """Retrieve a trace by ID.""" return mlflow.get_trace(trace_id) def search_traces( experiment_names: list[str] | None = None, **kwargs: Any, ) -> Any: """Search for traces, optionally filtered by experiment names.""" if experiment_names is None: experiment_names = ["Default"] return mlflow.search_traces(experiment_names=experiment_names, **kwargs)