Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <name>`) with `gen_ai.tool.name`, the
arguments and result as `input.value`/`output.value`, latency, and error status
(including tools that return `isError` results).
Expand Down
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
8 changes: 3 additions & 5 deletions src/rius/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
Expand Down
60 changes: 49 additions & 11 deletions src/rius/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
)


Expand Down Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions tests/test_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,92 @@ 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

# 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"]

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"
)
6 changes: 0 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.