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:
parent
9f0843ab6d
commit
4a19d39e63
12 changed files with 928 additions and 16 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
235
src/portfolio_optimiser/tracing.py
Normal file
235
src/portfolio_optimiser/tracing.py
Normal 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 (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 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}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue