Skip to content

symfonic.observability.otel

otel

OpenTelemetry exporter for symfonic-core (Roadmap Item 10 / PR-5b).

Public API: OTelExporter. All opentelemetry.* imports live in the sibling modules (tracer.py, callback_bridge.py, framework_bridge.py) and are lazy-loaded through OTelExporter.build(). Importing this package alone pulls zero opentelemetry modules -- verified by the lazy-import guard test.

See .claude/docs/2026-05-22-otel-exporter-design.md for the approved design.

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)