feat(tracing): U14 - sporing er opt-in, og "av" betyr at MAF aldri kalles (ORDRE 20260823T165757Z)

PORTFOLIO_OTEL er eneste bryter, lest paa truthiness. Uten den kalles
configure_otel_providers ikke i det hele tatt: spans lages fortsatt
(ENABLE_INSTRUMENTATION defaulter True) og kastes, saa ingenting KAN forlate
prosessen. `console` skriver spans til stderr - demoens stdout er byte-identisk
med fasiten, maalt. `otlp` eksporterer over nett, og kun mot et endepunkt
operatoeren selv har navngitt.

To regler er MAALT, ikke valgt (observability.py:849 bygger exporter-lista i
fast rekkefoelge):

- enable_console_exporters sendes EKSPLISITT False i begge moduser. Overlatt til
  miljoeet faller den tilbake paa ENABLE_CONSOLE_EXPORTERS, hvis
  ConsoleSpanExporter skriver til STDOUT - nettopp det S6 maalte som oedeleggende
  for goldenen.
- `console` NEKTER naar en OTEL_EXPORTER_OTLP_*_ENDPOINT finnes: env-avledede
  exportere bygges UBETINGET og FOER vaare, saa ordet "console" ville vaert en
  usann paastand om hvor kjoeringens innhold tok veien. Validering, ALDRI
  reparasjon - vi fjerner ikke operatoerens variabel bak ryggen paa dem.

Tre kallsteder (run.main, simulation.main, hosting.main): demoen er et skriptet
bevis, ikke produktet, og en soem bare demoen naar ville latt de to inngangene en
virksomhet faktisk kjoerer vaere usporbare. tracing_notice er ENESTE renderer og
returnerer None naar sporing er av - omisjon, aldri tom rad.

IKKE bygget, med grunn: PLAN_CREATED/REPLANNED/PROGRESS_LEDGER_UPDATED hoerer til
sloeyfa U4 bygger; en emitter uten kallsted er en form gjettet i stedet for maalt.
OTLP-exporter-PAKKENE er bevisst ikke deklarert (egress + grpc/protobuf-vekt i et
publisert wheel); uttalt i README/DEPLOY/env.template.

Ny dep: opentelemetry-sdk>=1.42,<2 (operatoerbeslutning 2, 23.08). EN pakke, ikke
to - ConsoleSpanExporter bor inne i sdk-en. opentelemetry-api fulgte med
1.42.1 -> 1.44.0, maalt uskadelig.

Load-bearing MAALT (tests/test_tracing_loadbearing.py), ni mutasjoner alle roede
mot HELE suiten + groenn kontroll 943/5. Tre av de roede bor i tester som fantes
fra foer (golden-transkriptets fire-linjers stderr + portefoelje-CLI-ens stille
pass), altsaa er omisjons-regelen gatet av uavhengige vitner.

Golden ea8c534... uendret. mypy src + ruff rene.

Ordren tar ogsaa de fire operatoerbeslutningene inn i planens paragraf F.
Laasen paa orchestrations 1.0.1 er ENDELIG (operatoerbekreftelse 23.08), ikke
midlertidig: spike-ordrens "revert hvis E7 staar" er overstyrt av den senere
beslutningen, som betinget paa groenn suite - ikke paa E7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-08-23 20:51:46 +02:00
commit 4a19d39e63
12 changed files with 928 additions and 16 deletions

View file

@ -65,11 +65,13 @@ import asyncio
import json
import os
import signal
import sys
from typing import Any
from portfolio_optimiser.budget import BudgetExceeded
from portfolio_optimiser.outbox import outcome_payload
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.tracing import configure_tracing, tracing_notice
DEFAULT_PORT = 8088
_HOSTED_DEFAULT_PROFILE = "azure"
@ -257,5 +259,15 @@ async def _serve_until_sigterm() -> None:
def main() -> None:
"""Serve the hosted-agent contract until SIGTERM (bind 0.0.0.0 — the platform
terminates TLS in front of us), then exit 0."""
terminates TLS in front of us), then exit 0.
U14: the tracing seam is installed before the loop starts and announced on stderr, which in a
container IS the log. This is the entry where "an organisation must be able to see what the run
did" is actually cashed — the demo is a scripted proof, not the product. A malformed
``PORTFOLIO_OTEL`` propagates: a server whose telemetry cannot be configured as asked must not
start and then look healthy on ``/readiness``."""
setup = configure_tracing()
notice = tracing_notice(setup)
if notice is not None:
print(notice, file=sys.stderr)
asyncio.run(_serve_until_sigterm())

View file

@ -75,6 +75,7 @@ from portfolio_optimiser.mcp_tools import (
)
from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects
from portfolio_optimiser.tracing import TracingConfigError, configure_tracing, tracing_notice
from portfolio_optimiser.validator import Rejection, ValidatedProposal, baseline_from_project
from portfolio_optimiser import okf, outbox
from portfolio_optimiser.semretrieval import (
@ -1506,6 +1507,21 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
# U14: the tracing seam, resolved FIRST — ahead of every branch that can return, because MAF's
# contract is "call once at startup, before any telemetry is captured". Without PORTFOLIO_OTEL
# this configures nothing at all and prints nothing (omission, never an empty row), so every
# existing stderr expectation in the suite is untouched. A malformed request exits through this
# CLI's own refusal surface (printed line + rc 1) rather than as a traceback: it is something
# the operator exported and can fix, which is exactly what that surface is for.
try:
tracing_setup = configure_tracing()
except TracingConfigError as exc:
print(f"run refused: {exc}", file=sys.stderr)
return 1
tracing_line = tracing_notice(tracing_setup)
if tracing_line is not None:
print(tracing_line, file=sys.stderr)
# S5.4: read-only value-report dispatch — placed FIRST (right after parse_args, BEFORE the
# mode-exclusivity block below) so it returns before any model/portfolio path can start and no
# later branch can shadow it (the bare `--ledger`-outside-portfolio refusal at the elif below is

View file

@ -43,6 +43,7 @@ from portfolio_optimiser.ir import AffectedItem, CostBaseline, CostBaselineLine,
from portfolio_optimiser.persona import load_persona_example
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.shared_root import shared_root
from portfolio_optimiser.tracing import configure_tracing, tracing_notice
from portfolio_optimiser.validator import ValidatedProposal
from portfolio_optimiser.verdicts import (
Verdict,
@ -817,6 +818,16 @@ def main(argv: list[str] | None = None) -> int: # pragma: no cover - console tr
# library consumer of this module keeps its own logging configuration.
quiet_expected_round_cap_notice()
# U14: install OpenTelemetry providers if — and only if — PORTFOLIO_OTEL asks for it, BEFORE
# any workflow runs (MAF's own "call once at startup, before telemetry is captured"). Absent
# the variable this configures nothing at all, which is what keeps the pinned stdout AND the
# four-line pinned stderr byte-identical; console mode writes spans to stderr, never stdout.
# The announcement goes to stderr for the same reason.
setup = configure_tracing()
notice = tracing_notice(setup)
if notice is not None:
print(notice, file=sys.stderr)
work = tempfile.mkdtemp(prefix="po-sim-")
# THE call site (P3, GO): the demo runs the DELIVERED bundle, which ships its own
# `cost-baseline.json` — so the gate is anchored on numbers a domain team wrote, not on numbers

View file

@ -0,0 +1,235 @@
"""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. Equally absent are the
``PLAN_CREATED`` / ``REPLANNED`` / ``PROGRESS_LEDGER_UPDATED`` events the plan names they belong
to the exploration loop (U4), which does not exist yet, and an emitter written before its call site
is a shape guessed rather than measured.
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 (-(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 four lines, and a "tracing: off" row would have made it five.
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}"
)