Skip to content

symfonic.observability.otel.exporter

exporter

Factory for OTelExporter -- the single gated entry into the OTEL surface.

This module is intentionally free of opentelemetry imports at module top level. The factory imports the OTEL-laden sibling modules (tracer.py, callback_bridge.py, framework_bridge.py) inside OTelExporter.build() so a disabled config never pulls OTEL into sys.modules.

See .claude/docs/2026-05-22-otel-exporter-design.md §8 for the lazy-import contract and §7 for the bridge-adapter rationale.

OTelExporter

Public factory for the OTEL exporter handles.

Used as::

handles = OTelExporter.build(
    endpoint=config.otel_endpoint,
    service_name=config.otel_service_name,
    capture_prompts=config.otel_capture_prompts,
)

Returns None when the SDK is not installed (the validator on FrameworkConfig should already have rejected otel_enabled=True in that case, but the factory keeps a belt-and-braces check for the direct-test path).

build staticmethod

build(
    *,
    endpoint: str | None,
    service_name: str,
    capture_prompts: bool,
    span_exporter: Any | None = None,
    use_batch_processor: bool | None = None,
) -> OTelExporterHandles | None

Construct the tracer + bridges. Imports the OTEL surface lazily.

Parameters:

Name Type Description Default
endpoint str | None

OTLP target URL or None to fall back to env vars.

required
service_name str

service.name resource attribute.

required
capture_prompts bool

Privacy gate for prompt / tool args / outputs.

required
span_exporter Any | None

Optional pre-built exporter for tests. When supplied, the OTLP gRPC exporter is NOT constructed.

None
use_batch_processor bool | None

Optional override; tests pass False for deterministic span readback. Defaults to True for production OTLP exporters and False when a custom exporter is supplied.

None

Returns:

Type Description
OTelExporterHandles | None

OTelExporterHandles on success, None when the OTEL SDK

OTelExporterHandles | None

could not be imported. The FrameworkConfig._validate_otel_extra

OTelExporterHandles | None

validator is the primary gate; this fallback exists so direct

OTelExporterHandles | None

callers (tests) get a typed signal instead of an ImportError.

Source code in src/symfonic/observability/otel/exporter.py
@staticmethod
def build(
    *,
    endpoint: str | None,
    service_name: str,
    capture_prompts: bool,
    span_exporter: Any | None = None,
    use_batch_processor: bool | None = None,
) -> OTelExporterHandles | None:
    """Construct the tracer + bridges. Imports the OTEL surface lazily.

    Args:
        endpoint: OTLP target URL or ``None`` to fall back to env vars.
        service_name: ``service.name`` resource attribute.
        capture_prompts: Privacy gate for prompt / tool args / outputs.
        span_exporter: Optional pre-built exporter for tests. When
            supplied, the OTLP gRPC exporter is NOT constructed.
        use_batch_processor: Optional override; tests pass ``False``
            for deterministic span readback. Defaults to ``True`` for
            production OTLP exporters and ``False`` when a custom
            exporter is supplied.

    Returns:
        ``OTelExporterHandles`` on success, ``None`` when the OTEL SDK
        could not be imported. The ``FrameworkConfig._validate_otel_extra``
        validator is the primary gate; this fallback exists so direct
        callers (tests) get a typed signal instead of an ``ImportError``.
    """
    try:
        from .callback_bridge import CallbackBridge
        from .framework_bridge import FrameworkBridge
        from .tracer import OTelTracer
    except ImportError:
        logger.warning(
            "OTel SDK not importable; OTelExporter.build() returning None. "
            "Install the 'otel' extra: pip install symfonic-core[otel]",
        )
        return None

    tracer = OTelTracer(
        endpoint=endpoint,
        service_name=service_name,
        capture_prompts=capture_prompts,
        span_exporter=span_exporter,
        use_batch_processor=use_batch_processor,
    )
    return OTelExporterHandles(
        callback_bridge=CallbackBridge(tracer),
        framework_bridge=FrameworkBridge(tracer),
        tracer=tracer,
    )

OTelExporterHandles dataclass

OTelExporterHandles(
    callback_bridge: CallbackBridge,
    framework_bridge: FrameworkBridge,
    tracer: OTelTracer,
)

Bundle of references the engine wires into its registration sites.

The engine prepends callback_bridge to per-invocation callback lists, hands framework_bridge to the framework-hook dispatcher, and keeps tracer for the start_run_span context manager plus shutdown on agent close.

register_into

register_into(
    *,
    callback_handlers: list[CallbackHandler],
    framework_hooks: list[Any],
) -> None

Append the two bridges to their respective registration sites.

Both lists are mutated in place. The engine owns the lists, so the exporter does not need to track membership separately. Tests can verify wiring by inspecting the lists after construction.

Source code in src/symfonic/observability/otel/exporter.py
def register_into(
    self,
    *,
    callback_handlers: list[CallbackHandler],
    framework_hooks: list[Any],
) -> None:
    """Append the two bridges to their respective registration sites.

    Both lists are mutated in place. The engine owns the lists, so
    the exporter does not need to track membership separately. Tests
    can verify wiring by inspecting the lists after construction.
    """
    callback_handlers.append(self.callback_bridge)  # type: ignore[arg-type]
    framework_hooks.append(self.framework_bridge)

build_if_enabled

build_if_enabled(config: Any) -> OTelExporterHandles | None

Convenience wrapper: return handles when config.otel_enabled.

Used by SymfonicAgent.__init__ so the engine site stays a single-line statement. When otel_enabled is False this function returns None without touching any module under symfonic.observability.otel.tracer/etc, preserving the zero-cost invariant.

Source code in src/symfonic/observability/otel/exporter.py
def build_if_enabled(config: Any) -> OTelExporterHandles | None:
    """Convenience wrapper: return handles when ``config.otel_enabled``.

    Used by ``SymfonicAgent.__init__`` so the engine site stays a
    single-line statement. When ``otel_enabled`` is False this function
    returns ``None`` **without** touching any module under
    ``symfonic.observability.otel.tracer``/etc, preserving the zero-cost
    invariant.
    """
    if not getattr(config, "otel_enabled", False):
        return None
    endpoint = getattr(config, "otel_endpoint", None)
    service_name = getattr(config, "otel_service_name", "symfonic")
    capture_prompts = getattr(config, "otel_capture_prompts", False)
    span_exporter = getattr(config, "_otel_test_span_exporter", None)
    use_batch_processor = getattr(config, "_otel_test_use_batch", None)
    return OTelExporter.build(
        endpoint=endpoint,
        service_name=service_name,
        capture_prompts=capture_prompts,
        span_exporter=span_exporter,
        use_batch_processor=use_batch_processor,
    )