MAF core 1.9.0 -> 1.16.0, orchestrations 1.0.1 -> 1.1.1. De to kan ikke loeftes hver for seg: orchestrations 1.1.1 krever selv core>=1.15.0. Iron Law: vakt-testen kjoert ROED mot 1.9.0 (2 failed) FOER pinnen ble roert. Gulvet bor i EN konstant og pyproject-asserten deriverer sin streng fra den. NEVNER: 16 private/ugaranterte former, derivert fra repoets EGNE siteringer, alle 16 sjekket mot begge versjoner, 2 endret seg. Kjent-positiv: MiddlewareFailure flippet NO -> YES. KP-kandidaten _compaction.py ble FORKASTET (teller 0 i begge, diskriminerer ingenting). DEN FARLIGE ENDRINGEN er den ordren navnga - formen som fortsatt importerer, men har flyttet semantikk i stillhet. En park skriver naa TO checkpoints og bare EN baerer plan-review-typen, saa en feildeklarert _ALLOWED_CHECKPOINT_TYPES toemmer ikke lenger listingen: den taper nOEyaktig den checkpointen som betyr noe, get_latest returnerer den ANDRE, og _parks `latest is None`-vakt passerte mens kjOEringen svarte rc=0 og skrev et spOErsmaal som aldri kan baere svaret. Vakten sjekker naa EGENSKAPEN den alltid mente (request_id in pending_request_info_events - et DEKLARERT felt) i stedet for symptomet som pleide aa innebaere den, og fjerner dermed en privat avhengighet i stedet for aa legge til en. ExperimentalWarning-paret P4 pkt. 2 betalte for aa BEHOLDE er borte fordi MAF sluttet aa sende det: _feature_stage.py emitterer ved FOERSTE BRUK, ikke ved import. Goldenens stderr regenerert som BESLUTNING (fire -> to linjer); site-packages- maskeringen BEHOLDT (spannet er ubebodd, ikke pensjonert). Load-bearing MAALT mot HELE suiten, gronn kontroll 1089/5, stdout BYTE-UENDRET (ea8c534773acdbe41ae68f2c55724d69aaf8be4f): M1 revert av vakten -> 1 rod. EN mutasjon ble IKKE rod og staar som aerlighets-grense, ikke som gate: spikens checkpoint_ids[-1] er rekkefolge-avhengig (Path.glob), altsaa flaky. Rapport: docs/2026-09-02-f15-maf-pinnen.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
14 KiB
Python
265 lines
14 KiB
Python
"""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)
|