Files
fastmcp-feedback/tests/unit/test_consistency.py
T
rsp2k 7892a5d79f Release 2026.10.01.4: statistics labels, event identity, quiet logging
README and CONTRIBUTING rewritten where they described the old behavior
or code that does not exist: the example test and mixin recipe now run,
MySQL is marked untested, the two Postgres driver paths are spelled out,
and the coverage figure is the measured 95%.
2026-10-01 22:48:46 -06:00

524 lines
18 KiB
Python

"""Statistics labels, event identity and logging, as fixed in 2026.10.01.4.
Statistics run on SQLite and, with FFB_TEST_DATABASE_URL set, on Postgres
through the feedback tools' own synchronous engine (psycopg2).
"""
import asyncio
import contextlib
import json
import os
import subprocess
import sys
import tomllib
from pathlib import Path
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from packaging.requirements import Requirement
from sqlalchemy import String, select, text, type_coerce
from sqlalchemy.engine import make_url
import fastmcp_feedback
from fastmcp_feedback import (
FeedbackDatabase,
ManagementMixin,
RetrievalMixin,
SubmissionMixin,
SubmitFeedbackRequest,
get_database_stats,
)
from fastmcp_feedback.database import enum_label
from fastmcp_feedback.instrumentation import REDACTED, MemorySink, instrument
from fastmcp_feedback.models import Feedback, FeedbackStatus, FeedbackType
ROOT = Path(__file__).resolve().parents[2]
# Statistics
@pytest.fixture
async def feedback_db(db_backend, db_url):
"""A FeedbackDatabase on an empty database of the current backend."""
url = make_url(db_url("feedback"))
if db_backend == "postgres":
pytest.importorskip("psycopg2")
url = url.set(drivername="postgresql+psycopg2")
else:
url = url.set(drivername="sqlite")
db = FeedbackDatabase(url.render_as_string(hide_password=False))
await db.initialize()
yield db
await db.close()
async def seed(db):
"""Two bugs (one in progress) and a feature, through the tools."""
submit = SubmissionMixin(db)
ids = []
for kind in ("bug", "bug", "feature"):
result = await submit.submit_feedback(
SubmitFeedbackRequest(
type=kind, title=f"a {kind}", description="d", submitter="t"
)
)
assert result["success"], result
ids.append(result["feedback_id"])
updated = await ManagementMixin(db).update_feedback_status(
ids[0], FeedbackStatus.IN_PROGRESS
)
assert updated["success"], updated
return ids
class TestStatisticsLabels:
async def test_database_stores_enum_names(self, feedback_db):
# Why the mapping exists: the columns hold names on both backends.
await seed(feedback_db)
async with feedback_db.session() as session:
stored = session.execute(
select(type_coerce(Feedback.type, String)).distinct()
).scalars()
assert set(stored) == {"BUG", "FEATURE"}
async def test_statistics_keyed_by_value(self, feedback_db):
await seed(feedback_db)
stats = await RetrievalMixin(feedback_db).get_feedback_statistics()
assert "error" not in stats, stats
assert stats["total_count"] == 3
assert stats["by_type"] == {"bug": 2, "feature": 1}
assert stats["by_status"] == {"open": 2, "in_progress": 1}
async def test_statistics_keys_match_list_feedback(self, feedback_db):
await seed(feedback_db)
retrieval = RetrievalMixin(feedback_db)
stats = await retrieval.get_feedback_statistics()
listed = (await retrieval.list_feedback(per_page=100))["feedback"]
assert set(stats["by_type"]) == {f["type"] for f in listed}
assert set(stats["by_status"]) == {f["status"] for f in listed}
async def test_filter_by_statistics_key(self, feedback_db):
await seed(feedback_db)
retrieval = RetrievalMixin(feedback_db)
stats = await retrieval.get_feedback_statistics()
for kind, count in stats["by_type"].items():
listed = await retrieval.list_feedback(type_filter=kind)
assert listed["total_count"] == count
async def test_database_stats_keyed_by_value(self, feedback_db):
await seed(feedback_db)
stats = await get_database_stats(feedback_db)
assert stats["by_type"] == {"bug": 2, "feature": 1}
assert stats["by_status"] == {"open": 2, "in_progress": 1}
async def test_empty_database(self, feedback_db):
stats = await RetrievalMixin(feedback_db).get_feedback_statistics()
assert stats["by_type"] == {} and stats["by_status"] == {}
class TestEnumLabel:
@pytest.mark.parametrize(
("enum_cls", "stored", "label"),
[
(FeedbackType, FeedbackType.BUG, "bug"),
(FeedbackType, "BUG", "bug"),
(FeedbackType, "bug", "bug"),
(FeedbackStatus, "IN_PROGRESS", "in_progress"),
(FeedbackStatus, "in_progress", "in_progress"),
(FeedbackStatus, "archived", "archived"),
],
)
def test_label(self, enum_cls, stored, label):
assert enum_label(enum_cls, stored) == label
async def test_rows_written_as_values_are_merged(self, tmp_path):
# A row holding the value instead of the name (written by hand, or by
# another tool) is counted under the same key, not dropped.
db = FeedbackDatabase(f"sqlite:///{tmp_path}/legacy.db")
await db.initialize()
try:
await seed(db)
async with db.session() as session:
session.execute(
text(
"INSERT INTO feedback (type, title, description, submitter,"
" status, created_at, updated_at) VALUES ('bug', 't', 'd',"
" 's', 'open', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
session.commit()
stats = await RetrievalMixin(db).get_feedback_statistics()
assert stats["by_type"] == {"bug": 3, "feature": 1}
assert stats["by_status"] == {"open": 3, "in_progress": 1}
finally:
await db.close()
# Event identity
def make_app(resolver=None, **kwargs):
sink = MemorySink()
app = FastMCP("identity")
holder = {"spawned": []}
@app.tool
async def submit(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
def submit_sync(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
async def submit_as(job_id: str) -> dict:
holder["mw"].record_event(
"job.submitted",
key=job_id,
user_sub="given-user",
caller_kind="given-kind",
client_id="given-client",
)
return {"job_id": job_id}
@app.tool
async def chat() -> str:
holder["mw"].record_llm_call(model="m", provider="p")
return "ok"
@app.tool
async def fails() -> str:
holder["mw"].record_event("job.failed")
raise RuntimeError("no")
@app.tool
async def spawn(job_id: str) -> dict:
"""Start a task that records after this call has returned."""
started = asyncio.Event()
async def watcher():
started.set()
await asyncio.sleep(0.05)
holder["mw"].record_event("job.started", key=job_id)
holder["spawned"].append(asyncio.get_running_loop().create_task(watcher()))
await started.wait()
return {"job_id": job_id}
mw = instrument(app, [sink], identity_resolver=resolver, **kwargs)
holder["mw"] = mw
return app, sink, mw, holder["spawned"]
async def call(app, mw, *calls):
async with Client(app) as client:
for tool, args in calls:
with contextlib.suppress(ToolError):
await client.call_tool(tool, args)
await mw.flush()
class Resolver:
"""Counts its invocations; returns a different user each time."""
def __init__(self, extra=None):
self.calls = 0
self.extra = extra or {}
def __call__(self, context):
self.calls += 1
return {
"user_sub": f"user-{self.calls}",
"caller_kind": "pat",
"client_id": "addon",
"org": "acme",
**self.extra,
}
class AsyncResolver(Resolver):
async def __call__(self, context):
await asyncio.sleep(0)
return Resolver.__call__(self, context)
IDENTITY = ("user-1", "pat", "addon")
def ident(record):
return (record.user_sub, record.caller_kind, record.client_id)
class TestEventIdentity:
@pytest.mark.parametrize("resolver_cls", [Resolver, AsyncResolver])
async def test_event_in_call_gets_resolver_identity(self, resolver_cls):
who = resolver_cls()
app, sink, mw, _ = make_app(who)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert ident(ev) == IDENTITY
assert ident(rec) == IDENTITY
assert ev.call_id == rec.id
assert rec.identity == {"org": "acme"}
async def test_sync_tool_event_gets_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("submit_sync", {"job_id": "j1"}))
await asyncio.sleep(0.01)
await mw.flush()
[ev] = sink.events
assert ident(ev) == IDENTITY
async def test_llm_call_event_gets_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("chat", {}))
[ev] = sink.events
assert ev.kind == "llm.call" and ident(ev) == IDENTITY
async def test_failing_call_event_and_record_share_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("fails", {}))
[rec] = sink.calls
[ev] = sink.events
assert rec.outcome == "error"
assert ident(ev) == ident(rec) == IDENTITY
async def test_resolver_runs_once_per_call(self):
who = Resolver()
app, sink, mw, _ = make_app(who)
await call(
app,
mw,
("submit", {"job_id": "a"}),
("chat", {}),
("fails", {}),
("submit", {"job_id": "b"}),
)
assert who.calls == 4
assert [r.user_sub for r in sink.calls] == [
"user-1",
"user-2",
"user-3",
"user-4",
]
# Each event carries its own call's identity.
assert [(e.user_sub, e.call_id) for e in sink.events] == [
(r.user_sub, r.id) for r in sink.calls
]
async def test_explicit_identity_wins(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("submit_as", {"job_id": "j1"}))
[ev] = sink.events
assert ident(ev) == ("given-user", "given-kind", "given-client")
assert ident(sink.calls[0]) == IDENTITY
async def test_task_after_call_gets_no_identity(self):
who = Resolver()
app, sink, mw, spawned = make_app(who)
await call(app, mw, ("spawn", {"job_id": "j1"}))
await asyncio.gather(*spawned)
await mw.flush()
[rec] = sink.calls
[ev] = sink.events
assert ident(rec) == IDENTITY
assert ev.kind == "job.started"
assert ev.call_id is None and ev.session_id is None
assert ident(ev) == (None, None, None)
assert who.calls == 1
async def test_outside_call_unchanged(self):
who = Resolver()
app, sink, mw, _ = make_app(who)
mw.record_event("job.finished", key="j1")
mw.record_event("job.finished", key="j2", user_sub="u9", client_id="c9")
await mw.flush()
first, second = sink.events
assert ident(first) == (None, None, None) and first.call_id is None
assert ident(second) == ("u9", None, "c9")
assert who.calls == 0
async def test_identity_redacted_for_events(self):
token = "bmcp_abcdefgh12345678"
app, sink, mw, _ = make_app(Resolver({"caller_kind": f"pat:{token}"}))
await call(app, mw, ("submit", {"job_id": "j1"}))
[ev] = sink.events
assert ev.caller_kind == f"pat:{REDACTED}"
assert sink.calls[0].caller_kind == f"pat:{REDACTED}"
async def test_failing_resolver_leaves_identity_empty(self):
calls = []
def boom(context):
calls.append(1)
raise RuntimeError("resolver bug")
app, sink, mw, _ = make_app(boom)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert rec.ok and ev.call_id == rec.id
assert ev.user_sub is None and rec.user_sub is None
assert calls == [1]
async def test_slow_resolver_cut_off_before_tool(self):
async def slow(context):
await asyncio.sleep(5)
return {"user_sub": "late"}
app, sink, mw, _ = make_app(slow, hook_timeout=0.05)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert rec.user_sub is None and ev.user_sub is None and rec.ok
async def test_mode_off_runs_no_resolver(self):
who = Resolver()
app, sink, mw, _ = make_app(who, mode="off")
await call(app, mw, ("submit", {"job_id": "j1"}))
assert who.calls == 0 and sink.calls == [] and sink.events == []
async def test_excluded_tool_runs_no_resolver(self):
who = Resolver()
app, sink, mw, _ = make_app(who, exclude_tools={"submit"})
await call(app, mw, ("submit", {"job_id": "j1"}))
[ev] = sink.events
assert who.calls == 0 and ev.user_sub is None and ev.call_id is None
# Logging
def run_python(code, *args, env=None):
return subprocess.run(
[sys.executable, "-c", code, *args],
capture_output=True,
text=True,
timeout=60,
env=env,
)
class TestLogging:
def test_import_configures_nothing_and_prints_nothing(self):
proc = run_python(
# FastMCP 2.x and 3.0 reset warning filters on import and authlib
# then warns; drop warnings so stderr holds only logging output.
"import warnings\nwarnings.showwarning = lambda *a, **k: None\n"
"import logging, fastmcp_feedback, fastmcp_feedback.instrumentation\n"
"root = logging.getLogger()\n"
"print(len(root.handlers), root.level)\n"
"print([type(h).__name__ for h in"
" logging.getLogger('fastmcp_feedback').handlers])\n"
)
assert proc.returncode == 0, proc.stderr
assert proc.stderr == ""
handlers, names = proc.stdout.splitlines()
assert handlers == f"0 {30}" # no handler, level still WARNING
assert names == "['NullHandler']"
def test_import_logs_nothing_above_debug(self):
# A host that set up logging first sees only the DEBUG line.
proc = run_python(
"import logging, json\n"
"seen = []\n"
"class H(logging.Handler):\n"
" def emit(self, r):\n"
" if r.name.startswith('fastmcp_feedback'):\n"
" seen.append((r.levelname, r.getMessage()))\n"
"logging.basicConfig(level=logging.DEBUG, handlers=[H()])\n"
"import fastmcp_feedback\n"
"print(json.dumps(seen))\n"
)
assert proc.returncode == 0, proc.stderr
seen = json.loads(proc.stdout.splitlines()[-1])
assert [level for level, _ in seen] == ["DEBUG"]
assert "initialized" in seen[0][1]
def test_setup_logging_is_opt_in(self):
proc = run_python("import fastmcp_feedback\nfastmcp_feedback.setup_logging()\n")
assert proc.returncode == 0
assert "fastmcp_feedback - INFO - FastMCP Feedback v" in proc.stderr
def test_package_info_matches_pyproject(self):
pyproject = ROOT / "pyproject.toml"
if not pyproject.exists():
pytest.skip("not running from a source checkout")
project = tomllib.loads(pyproject.read_text())["project"]
info = fastmcp_feedback.PACKAGE_INFO
assert info["version"] == fastmcp_feedback.__version__ == project["version"]
def reqs(items):
return {str(Requirement(r)) for r in items}
assert reqs(info["dependencies"]) == reqs(project["dependencies"])
extras = project["optional-dependencies"]
assert set(info["optional_dependencies"]) == set(extras)
for name, items in extras.items():
if any(r.startswith("fastmcp-feedback") for r in items):
continue # self-references are expanded in the metadata
assert reqs(info["optional_dependencies"][name]) == reqs(items), name
assert info["classifiers"] == sorted(project["classifiers"])
class TestBackfillCliLogging:
@pytest.fixture
def stored_db(self, tmp_path):
"""A calls database with one error, written by the CLI's own sink."""
url = f"sqlite+aiosqlite:///{tmp_path}/calls.db"
code = (
"import asyncio, sys\n"
"from datetime import UTC, datetime\n"
"from fastmcp_feedback.instrumentation import DatabaseSink,"
" ToolCallRecord\n"
"async def main():\n"
" sink = DatabaseSink(sys.argv[1], create_tables=True)\n"
" await sink.write([ToolCallRecord(tool='t',"
" started_at=datetime.now(UTC), duration_ms=1, ok=False,"
" outcome='error', mode='meta', error_type='E',"
" error_message='broke')])\n"
" await sink.ensure_embeddings()\n"
" await sink.engine.dispose()\n"
"asyncio.run(main())\n"
)
proc = run_python(code, url)
assert proc.returncode == 0, proc.stderr
return url
def backfill(self, url, *flags):
env = {k: v for k, v in os.environ.items() if k != "FFB_EMBED_API_KEY"}
return subprocess.run(
[
sys.executable,
"-m",
"fastmcp_feedback.instrumentation.backfill",
"--database-url",
url,
"--dry-run",
*flags,
],
capture_output=True,
text=True,
env=env,
timeout=60,
)
def test_verbose_logs_at_info(self, stored_db):
proc = self.backfill(stored_db, "-v")
assert proc.returncode == 0, proc.stderr
assert "INFO fastmcp_feedback.instrumentation.backfill: Backfill dry run" in (
proc.stderr
)
assert json.loads(proc.stdout.splitlines()[-1])["call_error"] == 1
def test_quiet_without_verbose(self, stored_db):
proc = self.backfill(stored_db)
assert proc.returncode == 0, proc.stderr
assert "INFO" not in proc.stderr
assert "initialized" not in proc.stderr