From ad74e5327ff74a34a3dfeab14368fef7a3f2a9e3 Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Mon, 17 Aug 2026 10:53:27 +0200 Subject: [PATCH 1/2] feat!: drop the mcp extra so every extra means one thing An extra installs the instrumentation FOR a library and never the library itself, so the SDK cannot pin or upgrade a version the user's own code runs against. mcp broke that rule: its instrumentation ships in this package, so the only thing the extra could install was the library, and because the extra was also listed in [instruments], asking for all integrations put the mcp runtime library into environments that never touch MCP. MCP behaviour is unchanged. instrumentation_mcp imports mcp at module level and the registry treats ImportError as not-installed, so the entry enables itself exactly when the user has the package; verified in both directions. The registry gains the install source per entry, because the requested-but- missing warning previously hard-coded 'pip install glassflow-rius[]' for every entry and would have pointed at an extra that no longer exists. An entry now declares either the extra that installs its instrumentation or the library a built-in one patches, and a test asserts exactly one of the two is set so the warning can always name a fix. Also removes the GLASSFLOW_* prose from resolve_config's docstring, which was the last mention reaching the published docs (the generated reference page renders that docstring). The fallback still works and still warns; the module comment and _getenv's docstring continue to describe it for maintainers, neither of which is published. BREAKING CHANGE: the mcp extra no longer exists. pip warns about an unknown extra for anyone installing glassflow-rius[mcp], and installs succeed; MCP tracing continues to work as long as mcp itself is a dependency of the project, which it must be for the agent to call MCP tools at all. --- .github/workflows/ci.yml | 12 ++--- README.md | 18 +++++--- pyproject.toml | 13 +++++- src/rius/config.py | 8 ++-- src/rius/instrumentation.py | 60 ++++++++++++++++++++----- tests/test_instrumentation.py | 85 +++++++++++++++++++++++++++++++++++ uv.lock | 6 --- 7 files changed, 166 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd85ccd..417d820 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,13 @@ jobs: - run: uv sync --locked --group dev - run: uv run pytest - # The mcp extra is unbounded (mcp>=1.0.0) and the two majors have different - # result shapes (snake_case + InputRequiredResult in 2.x, GLA2-300); the - # default suite exercises the locked 1.x, this job re-runs the MCP - # instrumentation tests with 2.x overlaid so a future rename fails a build - # instead of silently un-flagging error results. + # MCP has no extra: the instrumentation ships in the SDK and activates when + # the user's own `mcp` package is importable, so any major they install is + # in scope. The two majors have different result shapes (snake_case + + # InputRequiredResult in 2.x, GLA2-300); the default suite exercises the + # dev group's 1.x, and this job re-runs the MCP instrumentation tests with + # 2.x overlaid so a future rename fails a build instead of silently + # un-flagging error results. mcp-v2: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index d7712d7..d884e9d 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,6 @@ variables: | `sample_rate` | `RIUS_SAMPLE_RATE` | `1.0` | Head sampling ratio `0.0`–`1.0` (whole-trace; children follow root). | | `capture_content` | `RIUS_CAPTURE_CONTENT` | `true` | When false, prompt/response content is stripped at export (metadata still sent). | -Every variable also accepts its former `GLASSFLOW_*` spelling (for example -`GLASSFLOW_API_KEY`). Those names are deprecated: they keep working for now, -log a warning naming the `RIUS_*` replacement, and will be removed in a -future release. When both spellings are set, `RIUS_*` wins. - `mask` is a code-only option (no env var): pass a callable to `init(mask=...)` and it is applied to every content attribute value at export, across our spans and any bundled third-party instrumentation. A mask that accepts a `key` keyword also @@ -83,6 +78,13 @@ pip install "glassflow-rius[openai]" # one provider pip install "glassflow-rius[instruments]" # everything supported ``` +An extra installs the instrumentation **for** a library, never the library +itself, so the SDK never pins or upgrades the versions your code runs against: +`glassflow-rius[anthropic]` expects `anthropic` to be your own dependency, +which it already is in any project that calls Anthropic. An extra installed +without its library makes `init()` log a `DependencyConflict` from +OpenTelemetry and leaves that one integration off. + ```python rius.init() # auto-enables installed instrumentors rius.init(instruments=["openai"]) # restrict to specific ones @@ -93,8 +95,10 @@ Supported instruments: `openai`, `anthropic`, `langchain`, `llama-index`, `litellm`, and `mcp`. Content captured by instrumentors is covered by the same `mask` / `capture_content` controls as our own spans. -The `mcp` instrument is built in: if the [`mcp`](https://pypi.org/project/mcp/) -package is installed, every `ClientSession.call_tool()` your agent makes becomes +The `mcp` instrument is built in and has **no extra**: there is no third-party +instrumentation to install, and an extra that installed `mcp` for you would +break the rule above. If the [`mcp`](https://pypi.org/project/mcp/) +package is present, every `ClientSession.call_tool()` your agent makes becomes a first-class TOOL span (`execute_tool `) with `gen_ai.tool.name`, the arguments and result as `input.value`/`output.value`, latency, and error status (including tools that return `isError` results). diff --git a/pyproject.toml b/pyproject.toml index b00ccc8..9088073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,19 +39,28 @@ dependencies = [ # Auto-instrumentation extras — one per entry in rius.instrumentation.REGISTRY. # Floors are the versions the registry smoke test last validated against; no caps # (these packages break within 0.x either way — CI catches forward breakage). +# Every extra installs the INSTRUMENTATION for a library and never the library +# itself, so the SDK cannot pin or upgrade the versions a user's code runs +# against. The instrumented library is always the user's own dependency; the +# instrumentors declare it under their own `instruments` extra, which we do not +# pull in. +# +# There is deliberately NO `mcp` extra: MCP is instrumented by our own module +# (instrumentation_mcp.py), so an instrumentation-only extra would install +# nothing. It activates whenever the `mcp` package is importable. An extra that +# installed `mcp` would be the one place the SDK added a runtime library to a +# user's environment, which is exactly what the rule above exists to prevent. openai = ["openinference-instrumentation-openai>=0.1.52"] anthropic = ["openinference-instrumentation-anthropic>=1.0.6"] langchain = ["openinference-instrumentation-langchain>=0.1.67"] llama-index = ["openinference-instrumentation-llama-index>=4.4.3"] litellm = ["openinference-instrumentation-litellm>=0.1.34"] -mcp = ["mcp>=1.0.0"] instruments = [ "openinference-instrumentation-openai>=0.1.52", "openinference-instrumentation-anthropic>=1.0.6", "openinference-instrumentation-langchain>=0.1.67", "openinference-instrumentation-llama-index>=4.4.3", "openinference-instrumentation-litellm>=0.1.34", - "mcp>=1.0.0", ] [project.urls] diff --git a/src/rius/config.py b/src/rius/config.py index 6203b99..750a2f7 100644 --- a/src/rius/config.py +++ b/src/rius/config.py @@ -172,11 +172,9 @@ def resolve_config( """Resolve SDK configuration from arguments, environment, then defaults. Explicit arguments win over ``RIUS_*`` environment variables, which win - over their deprecated ``GLASSFLOW_*`` spellings, which win over built-in - defaults. Using a ``GLASSFLOW_*`` variable logs one deprecation warning - naming the replacements. ``sample_rate`` is clamped to ``[0.0, 1.0]`` - with a warning; boolean environment variables accept ``1``/``true``/ - ``yes``/``on`` (case-insensitive). + over built-in defaults. ``sample_rate`` is clamped to ``[0.0, 1.0]`` with + a warning; boolean environment variables accept ``1``/``true``/``yes``/ + ``on`` (case-insensitive). Args: endpoint: Base OTLP endpoint (``RIUS_ENDPOINT``). diff --git a/src/rius/instrumentation.py b/src/rius/instrumentation.py index 95b27c5..53323af 100644 --- a/src/rius/instrumentation.py +++ b/src/rius/instrumentation.py @@ -5,6 +5,11 @@ packages are imported lazily, so nothing here adds a hard dependency. Install via extras (``pip install glassflow-rius[openai]``) and ``init()`` enables what it finds, passing our tracer provider so instrumentation spans nest under ours. + +An extra installs the instrumentation for a library, never the library itself: +the SDK must not pin or upgrade the versions a user's code runs against. MCP is +the one instrumentation we ship ourselves, so it has no extra at all and +enables itself when the ``mcp`` package is importable. """ from __future__ import annotations @@ -25,29 +30,52 @@ @dataclass(frozen=True) class InstrumentorSpec: - """A third-party instrumentor we know how to enable.""" + """An instrumentor we know how to enable.""" name: str module: str class_name: str + #: The extra that installs this instrumentation, or ``None`` when it ships + #: with the SDK. Only used to tell a user what to install when they request + #: an instrument whose module is missing, so the hint has to distinguish + #: "you need our extra" from "you need the library itself". + extra: str | None = None + #: For a built-in instrumentation (``extra is None``), the library whose + #: presence enables it. Set exactly one of ``extra`` or ``library``. + library: str | None = None # OpenInference instrumentors (Arize) — same conventions we emit natively. # OpenLLMetry entries can be added alongside; the backend normalizer covers both. REGISTRY: tuple[InstrumentorSpec, ...] = ( - InstrumentorSpec("openai", "openinference.instrumentation.openai", "OpenAIInstrumentor"), InstrumentorSpec( - "anthropic", "openinference.instrumentation.anthropic", "AnthropicInstrumentor" + "openai", "openinference.instrumentation.openai", "OpenAIInstrumentor", extra="openai" + ), + InstrumentorSpec( + "anthropic", + "openinference.instrumentation.anthropic", + "AnthropicInstrumentor", + extra="anthropic", ), InstrumentorSpec( - "langchain", "openinference.instrumentation.langchain", "LangChainInstrumentor" + "langchain", + "openinference.instrumentation.langchain", + "LangChainInstrumentor", + extra="langchain", ), InstrumentorSpec( - "llama-index", "openinference.instrumentation.llama_index", "LlamaIndexInstrumentor" + "llama-index", + "openinference.instrumentation.llama_index", + "LlamaIndexInstrumentor", + extra="llama-index", ), - InstrumentorSpec("litellm", "openinference.instrumentation.litellm", "LiteLLMInstrumentor"), - # ours — first-class MCP tool-call spans (see instrumentation_mcp.py) - InstrumentorSpec("mcp", "rius.instrumentation_mcp", "MCPInstrumentor"), + InstrumentorSpec( + "litellm", "openinference.instrumentation.litellm", "LiteLLMInstrumentor", extra="litellm" + ), + # Ours: first-class MCP tool-call spans (see instrumentation_mcp.py). No + # extra, because the instrumentation ships here; the module imports `mcp`, + # so this entry is enabled exactly when the user has that package. + InstrumentorSpec("mcp", "rius.instrumentation_mcp", "MCPInstrumentor", library="mcp"), ) @@ -80,12 +108,22 @@ def enable_instrumentations( module = importlib.import_module(spec.module) except ImportError: if instruments is not None: + # The fix differs by entry: an extra installs a third-party + # instrumentation, while the built-in MCP one needs the `mcp` + # library. Naming an extra that does not exist would send the + # user in circles. + if spec.extra is not None: + hint = f'install it via `pip install "glassflow-rius[{spec.extra}]"`' + else: + hint = ( + f"it is built in and enables itself once `{spec.library}` " + f"is installed (`pip install {spec.library}`)" + ) logger.warning( - "instrument %r requested but %r is not installed; " - 'install it via `pip install "glassflow-rius[%s]"`', + "instrument %r requested but %r is not importable; %s", spec.name, spec.module, - spec.name, + hint, ) continue try: diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index 2ad93ae..8b524e7 100644 --- a/tests/test_instrumentation.py +++ b/tests/test_instrumentation.py @@ -228,3 +228,88 @@ def test_openai_instrumentation_spans_nest_under_ours() -> None: finally: instrumentor.uninstrument() server.shutdown() + + +def test_missing_extra_instrument_hint_names_the_extra( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A third-party instrumentation is installed through our extra.""" + monkeypatch.setattr( + instrumentation, + "REGISTRY", + ( + InstrumentorSpec( + "anthropic", "package_that_is_not_installed", "NopeInstrumentor", extra="anthropic" + ), + ), + ) + with caplog.at_level("WARNING"): + init(span_exporter=InMemorySpanExporter(), set_global=False, instruments=["anthropic"]) + message = "\n".join(record.getMessage() for record in caplog.records) + assert 'pip install "glassflow-rius[anthropic]"' in message + + +def test_missing_builtin_instrument_hint_names_the_library( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A built-in instrumentation has no extra, so the hint must name the + library instead. Pointing at `glassflow-rius[mcp]` would send the user to + an extra that does not exist.""" + monkeypatch.setattr( + instrumentation, + "REGISTRY", + ( + InstrumentorSpec( + "mcp", "package_that_is_not_installed", "NopeInstrumentor", library="mcp" + ), + ), + ) + with caplog.at_level("WARNING"): + init(span_exporter=InMemorySpanExporter(), set_global=False, instruments=["mcp"]) + message = "\n".join(record.getMessage() for record in caplog.records) + assert "pip install mcp" in message + assert "glassflow-rius[mcp]" not in message + + +def test_every_spec_declares_exactly_one_install_source() -> None: + """Each entry is installed either through one of our extras or by the user + installing the library a built-in instrumentation patches. Both set, or + neither, means the requested-but-missing warning cannot name a fix.""" + for spec in instrumentation.REGISTRY: + assert (spec.extra is None) != (spec.library is None), spec.name + + +def test_extras_install_instrumentation_only_never_a_runtime_library() -> None: + """The packaging rule this SDK is built on: an extra installs the + instrumentation FOR a library, so the SDK never pins or upgrades a version + the user's own code runs against. `mcp` used to be the exception, and its + extra put a runtime library into environments that never touched MCP. + """ + from pathlib import Path + + import tomllib + + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + extras = tomllib.loads(pyproject.read_text())["project"]["optional-dependencies"] + + specs = {spec.name: spec for spec in instrumentation.REGISTRY} + aggregate = "instruments" + + # Every extra maps to a registry entry that names it, so a stray or + # renamed extra cannot drift away from the registry unnoticed. + for name in extras: + if name == aggregate: + continue + assert name in specs, f"extra {name!r} has no registry entry" + assert specs[name].extra == name + + # No extra exists for a built-in instrumentation, and nothing an extra + # installs is a library we merely instrument. + builtin_libraries = {spec.library for spec in specs.values() if spec.library} + for name, requirements in extras.items(): + assert name not in builtin_libraries, f"{name!r} is a built-in instrumentation" + for requirement in requirements: + package = requirement.split(">=")[0].split("==")[0].strip() + assert package not in builtin_libraries, ( + f"extra {name!r} installs {package!r}, a library the SDK only instruments" + ) diff --git a/uv.lock b/uv.lock index 4722cb6..e611568 100644 --- a/uv.lock +++ b/uv.lock @@ -392,7 +392,6 @@ anthropic = [ { name = "openinference-instrumentation-anthropic" }, ] instruments = [ - { name = "mcp" }, { name = "openinference-instrumentation-anthropic" }, { name = "openinference-instrumentation-langchain" }, { name = "openinference-instrumentation-litellm" }, @@ -408,9 +407,6 @@ litellm = [ llama-index = [ { name = "openinference-instrumentation-llama-index" }, ] -mcp = [ - { name = "mcp" }, -] openai = [ { name = "openinference-instrumentation-openai" }, ] @@ -427,8 +423,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", marker = "extra == 'instruments'", specifier = ">=1.0.0" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "openinference-instrumentation-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.0.6" }, { name = "openinference-instrumentation-anthropic", marker = "extra == 'instruments'", specifier = ">=1.0.6" }, { name = "openinference-instrumentation-langchain", marker = "extra == 'instruments'", specifier = ">=0.1.67" }, From 0e59922f3a05ddd766c8881a067bbaff074fece8 Mon Sep 17 00:00:00 2001 From: Pablo Pardo Garcia Date: Mon, 17 Aug 2026 10:56:58 +0200 Subject: [PATCH 2/2] test: skip the extras-metadata guard where tomllib is unavailable The guard reads pyproject.toml, and stdlib tomllib is 3.11+ while this package supports 3.10, so the 3.10 leg failed. The assertion is about static metadata rather than runtime behaviour, so proving it on the other four interpreters is enough; a tomli dev dependency to read one file on the oldest one is not worth it. Verified against a real 3.10: 16 passed, the guard skipped with its reason. --- tests/test_instrumentation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index 8b524e7..84c8c31 100644 --- a/tests/test_instrumentation.py +++ b/tests/test_instrumentation.py @@ -287,7 +287,11 @@ def test_extras_install_instrumentation_only_never_a_runtime_library() -> None: """ from pathlib import Path - import tomllib + # tomllib is stdlib from 3.11 and this package supports 3.10. The assertion + # is about static metadata rather than runtime behaviour, so proving it on + # the rest of the matrix is enough; a tomli dev dependency just to read one + # file on the oldest interpreter is not worth it. + tomllib = pytest.importorskip("tomllib", reason="stdlib tomllib requires Python 3.11+") pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" extras = tomllib.loads(pyproject.read_text())["project"]["optional-dependencies"]