"""U14 — the opt-in OpenTelemetry seam: one variable, two sinks, and no silent egress. A run of this framework already *makes* spans: MAF's ``ENABLE_INSTRUMENTATION`` defaults to True (``observability.py:697``), so every workflow, executor and chat call is instrumented — and, with no provider configured, every one of those spans is discarded. This module is the provider, and nothing else. It exists because the exploration loop being built on top of it (U4) hands a manager the freedom to choose its own next step, and the programme's ordering rule is that nothing which grants that freedom lands before the freedom can be *watched*. An organisation cannot be asked to trust an autonomous exploration it cannot read afterwards. **The contract, in one paragraph.** ``PORTFOLIO_OTEL`` is read on truthiness. Absent or empty: ``configure_otel_providers`` is not called at all, so nothing is configured and nothing can leave — "off" means off, not "on, exporting to nowhere". ``console``: spans are written to **stderr**, so a traced run and an untraced run print byte-identical stdout and the pinned demo transcript survives. ``otlp``: spans go over the network, and ONLY when the operator has named a destination in one of the standard ``OTEL_EXPORTER_OTLP_*_ENDPOINT`` variables. Anything else is refused by name. **Two rules here are measurements, not preferences.** ``configure_otel_providers`` composes its exporter list in a fixed order (``observability.py:849``): 1. exporters derived from the standard ``OTEL_EXPORTER_OTLP_*`` variables — **unconditionally**, 2. the exporters passed in as ``exporters=``, 3. a ``ConsoleSpanExporter()`` — default sink ``sys.stdout`` — when ``enable_console_exporters`` is true, taken from the argument *or*, if that is ``None``, from ``ENABLE_CONSOLE_EXPORTERS`` in the environment. Step 3 is why ``enable_console_exporters=False`` is passed explicitly in **both** modes: left to the environment, an operator with that variable exported gets a span dump on stdout, which spike S6 measured as destroying the golden transcript outright. Step 1 is why console mode **refuses** when an OTLP endpoint variable is present: the word "console" would otherwise be a false statement about where the run's contents went. The refusal names the variable and leaves it alone — validation, never repair, the same rule ``write_concept_file`` and ``load_optional_cost_baseline`` follow. Unsetting an operator's environment behind their back would be a fix that hides its own cause. **What is deliberately absent.** The OTLP exporter *packages* (``opentelemetry-exporter-otlp-proto-grpc`` / ``-http``) are not declared dependencies. They are egress, they drag grpc and protobuf into a published wheel for a mode that is off by default, and MAF already raises an ``ImportError`` that names the package to install. Stated honesty limit: ``PORTFOLIO_OTEL=otlp`` works only after the operator installs one of them. **The ``PLAN_CREATED`` / ``REPLANNED`` / ``PROGRESS_LEDGER_UPDATED`` events now exist** (U4, økt 56). They were held back here in økt 55 on the ground that an emitter written before its call site is a shape guessed rather than measured; the call site is ``explore._absorb``, and the events are recorded on the exploration span this module's ``exploration_tracer`` hands out. Nothing about the contract above changed: with tracing off there is no provider, so those events are discarded like every other span this process makes. MAF-touching by construction, so this module never enters the framework-neutral context layer (``okf.py``); the ``test_okf_is_maf_free`` guard keeps that boundary. """ from __future__ import annotations import os from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import IO, Any, Final #: The one variable that turns tracing on. Read on TRUTHINESS, never presence (the 4b rule): an #: exported-but-empty value is a shell accident, and treating it as a request would turn #: ``export PORTFOLIO_OTEL=`` into a fail-fast on an unknown mode. TRACING_ENV: Final = "PORTFOLIO_OTEL" MODE_CONSOLE: Final = "console" MODE_OTLP: Final = "otlp" #: The closed set. A value outside it is refused by name rather than falling back to off — an #: operator who asked for a trace and mistyped would otherwise get the black box this seam exists #: to remove, arrived at by accident and without a word. _MODES: Final = (MODE_CONSOLE, MODE_OTLP) #: Every standard variable that makes ``_get_exporters_from_env`` construct a NETWORK exporter. #: All four are checked, not just the base one: a run configured only via #: ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`` exports exactly the signal this seam is about. _OTLP_ENDPOINT_ENVS: Final = ( "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", ) class TracingConfigError(ValueError): """A tracing request that cannot be honoured as stated. ``ValueError`` so the hosted flate maps it to 400 through the existing arm rather than needing a new one: it is a malformed request about this process's configuration, not a crash. """ @dataclass(frozen=True) class TracingSetup: """What a run resolved about its own tracing — the single source both callers read. ``mode`` is ``None`` when tracing is off. ``endpoints`` carries ``(variable, value)`` pairs and is non-empty only in OTLP mode, so the announcement can name the variable the operator edits rather than only the URL. Both the configuring and the announcing descend from this one value: a renderer that re-read the environment would be a second resolution of the same rule, free to disagree with the run it describes (the ``cost_baseline_notice`` rule). """ mode: str | None endpoints: tuple[tuple[str, str], ...] def declared_otlp_endpoints(env: Mapping[str, str]) -> tuple[tuple[str, str], ...]: """Return the OTLP endpoint variables the environment actually declares, in a fixed order. Truthiness again: an exported-but-empty endpoint variable declares nothing, and MAF's own ``os.getenv`` reads would skip it too, so treating it as a declaration would make console mode refuse over a destination that does not exist. """ return tuple( (name, env[name].strip()) for name in _OTLP_ENDPOINT_ENVS if env.get(name, "").strip() ) def resolve_tracing_mode(env: Mapping[str, str]) -> str | None: """Read ``PORTFOLIO_OTEL`` into the closed mode set, or ``None`` when tracing is off.""" raw = env.get(TRACING_ENV, "").strip() if not raw: return None if raw not in _MODES: raise TracingConfigError( f"{TRACING_ENV}={raw!r} is not a tracing mode. Allowed: " + ", ".join(repr(mode) for mode in _MODES) + f". Unset {TRACING_ENV} to run without tracing." ) return raw def configure_tracing( *, env: Mapping[str, str] | None = None, stream: IO[str] | None = None, configure: Callable[..., None] | None = None, ) -> TracingSetup: """Install OpenTelemetry providers for this process if — and only if — asked to. Call ONCE, at process startup, before any telemetry is captured (MAF's own instruction; a second call is a no-op behind its ``_executed_setup`` guard). Returns what was resolved, so the caller can announce it without re-reading anything. ``configure`` is injectable for the same reason ``run._default_factory`` is: it is the one seam a test can observe without installing global providers into the pytest process. The real proof that the seam works is a subprocess running the actual demo, not this argument. """ env = os.environ if env is None else env mode = resolve_tracing_mode(env) endpoints = declared_otlp_endpoints(env) if mode is None: # Not "configure with nothing" — NOT CALLING is what makes "off" mean nothing can leave. # A call with an empty exporter list would still install providers and re-read every # OTEL_EXPORTER_OTLP_* variable in the ambient environment. return TracingSetup(mode=None, endpoints=()) if configure is None: # pragma: no cover - trivial default resolution from agent_framework.observability import configure_otel_providers configure = configure_otel_providers if mode == MODE_CONSOLE: if endpoints: named = ", ".join(name for name, _ in endpoints) raise TracingConfigError( f"{TRACING_ENV}={MODE_CONSOLE} promises that spans stay in this process, but the " f"environment declares a network exporter: {named}. OpenTelemetry exporters are " "built from those variables unconditionally, so the run would also ship its spans " f"over the wire. Unset them, or ask for {TRACING_ENV}={MODE_OTLP} and say so." ) # Imported here rather than at module scope: the exporter is constructed only in this # branch, and every importer of the CLI would otherwise pay for a mode that is off by # default. from opentelemetry.sdk.trace.export import ConsoleSpanExporter # `out=stream` is the whole of console mode's safety. The default sink is sys.stdout, and # stdout is byte-pinned by tests/golden/demo-transcript.stdout. exporters: list[Any] | None = [ConsoleSpanExporter(out=stream or _default_stream())] else: if not endpoints: raise TracingConfigError( f"{TRACING_ENV}={MODE_OTLP} was requested but no endpoint is declared. Set one of: " + ", ".join(_OTLP_ENDPOINT_ENVS) + ". Configuring providers with nowhere to export would produce a run that looks " "traced and is not." ) # No exporter of our own: MAF builds them from the standard variables, and a hand-rolled # second one would be the duplicate free to drift from the OTel spec. exporters = None # `enable_console_exporters=False` is EXPLICIT in both modes, and load-bearing in both: left as # None it falls back to ENABLE_CONSOLE_EXPORTERS in the environment, whose console exporter # writes to stdout. configure(enable_console_exporters=False, exporters=exporters) return TracingSetup(mode=mode, endpoints=endpoints) def _default_stream() -> IO[str]: """``sys.stderr`` resolved at CALL time, not import time. The demo replaces neither, but a caller that redirects ``sys.stderr`` before startup should get the redirected one — an import-time binding would have captured whatever was current when the module was first imported. """ import sys return sys.stderr def tracing_notice(setup: TracingSetup) -> str | None: """Render what a run says about its own tracing, or ``None`` when there is nothing to say. ONE renderer with N call sites, never N copies of the wording (kø-(p)), and it takes the already-resolved ``TracingSetup`` rather than an environment: the printed line and the providers that were installed then descend from the same single resolution. ``None`` when tracing is off — omission, never an empty row (``mandate.announce``'s rule, the one ``cost_baseline_notice`` and ``skipped_links_notice`` follow). Here it is load-bearing past style: the pinned demo stderr is two lines, and a "tracing: off" row would have made it three. The OTLP form names the VARIABLE beside the value, because the variable is what the operator edits — and prints one row per declared endpoint rather than only the first, so a run exporting logs and traces to different collectors declares both. English, like every other line this CLI prints. """ if setup.mode is None: return None if setup.mode == MODE_CONSOLE: return ( f" Tracing: {TRACING_ENV}={MODE_CONSOLE} — OpenTelemetry spans are written to stderr; " "nothing leaves this process" ) rows = "\n".join(f" {name} = {value}" for name, value in setup.endpoints) return ( f" Tracing: {TRACING_ENV}={MODE_OTLP} — OpenTelemetry spans are EXPORTED OVER THE NETWORK " f"to the endpoints declared below\n{rows}" ) #: The instrumentation scope every exploration span is created under. One name, so a collector #: can select this framework's own spans apart from MAF's (``invoke_agent``, ``workflow.run``) #: without matching on span names that MAF owns and may rename. EXPLORATION_TRACER_NAME: Final = "portfolio_optimiser.explore" def exploration_tracer() -> Any: """The tracer the exploration loop records its decisions on. ``get_tracer`` is safe to call whether or not a provider was installed: with none, OpenTelemetry hands back a no-op tracer and every span and event is discarded. That is the SAME shape MAF's own instrumentation already has (``ENABLE_INSTRUMENTATION`` defaults to True and its spans are thrown away for want of a provider), and it is what lets the exploration emit unconditionally. Gating emission on ``PORTFOLIO_OTEL`` would be a second resolution of a rule this module owns, free to disagree with the providers actually installed. A FUNCTION rather than a module-level tracer, and the reason is ordering: ``configure_tracing`` runs at process startup, and a tracer bound at import time would have been taken from the global provider that existed BEFORE it — a no-op one, permanently. It is also the seam a test substitutes a local provider through, without installing anything globally. """ from opentelemetry import trace return trace.get_tracer(EXPLORATION_TRACER_NAME)