Control volume
A polling tool can make up most of ffb_tool_calls. Three settings keep the
table to a useful size: exclusion, sampling and retention.
Exclude or sample busy tools
Section titled “Exclude or sample busy tools”from fastmcp import FastMCPfrom fastmcp_feedback.instrumentation import DatabaseSink, instrument
app = FastMCP("My Server")sink = DatabaseSink("sqlite+aiosqlite:///calls.db", create_tables=True)mw = instrument( app, [sink], exclude_tools={"heartbeat"}, # never recorded sample_rates={"pending_dispatches": 0.01}, # 1% of ok calls)- Excluded tools are not recorded at all and are left out of feedback links.
- Sampled tools keep a random fraction of their
okcalls. Failures are always recorded: a call whose outcome iserrororsoft_erroris kept whatever the rate, and so is a call that recorded an event. - Calls sampled out cost no hook or redaction work and are left out of feedback links too.
- Rates must be in (0, 1]; anything else raises
ValueErrorwhen the middleware is created.
Neither setting changes what the client receives, and neither applies to events.
Count what sampling left out
Section titled “Count what sampling left out”Sampled-in ok rows carry their rate in sample_rate. Everything recorded
unconditionally, including failures of sampled tools, has NULL there. So each
row stands for 1 / coalesce(sample_rate, 1) calls:
SELECT tool, SUM(1.0 / COALESCE(sample_rate, 1)) AS est_callsFROM ffb_tool_callsGROUP BY tool;Retention
Section titled “Retention”Give the DatabaseSink a retention period and it deletes older rows as it goes:
from datetime import timedelta
sink = DatabaseSink( "sqlite+aiosqlite:///calls.db", create_tables=True, retention=timedelta(days=30),)mw = instrument(FastMCP("Retained"), [sink])Pruning runs in the background writer after an insert, at most once per
prune_interval (an hour by default; the first write after startup prunes). It
deletes prune_batch rows (5000) per transaction, so it never holds a long
lock, and a failing prune is logged without affecting the insert.
What it deletes, by timestamp:
| Table | Deleted when | Kept |
|---|---|---|
ffb_tool_calls | started_at before the cutoff | Calls linked to feedback |
ffb_events | occurred_at before the cutoff | |
ffb_embeddings | created_at before the cutoff | Embeddings of feedback |
ffb_feedback_call_links | never |
So a report keeps its evidence however old it gets. Without retention,
nothing is deleted.
Prune on your own schedule
Section titled “Prune on your own schedule”Call prune() directly. It returns the total rows deleted from all three
tables and logs each table’s count:
import asynciofrom datetime import UTC, datetime
async def nightly(): deleted = await sink.prune() # now minus retention deleted += await sink.prune(older_than=datetime.now(UTC) - timedelta(days=7)) print("deleted", deleted) await mw.aclose()
asyncio.run(nightly())Turn it all off
Section titled “Turn it all off”mode="off" (or FEEDBACK_INSTRUMENTATION_MODE=off) passes every call straight
through and records nothing, events included. It is the quickest way to rule
the middleware out while debugging.