Skip to content

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.

from fastmcp import FastMCP
from 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 ok calls. Failures are always recorded: a call whose outcome is error or soft_error is 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 ValueError when the middleware is created.

Neither setting changes what the client receives, and neither applies to events.

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_calls
FROM ffb_tool_calls
GROUP BY tool;

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:

TableDeleted whenKept
ffb_tool_callsstarted_at before the cutoffCalls linked to feedback
ffb_eventsoccurred_at before the cutoff
ffb_embeddingscreated_at before the cutoffEmbeddings of feedback
ffb_feedback_call_linksnever

So a report keeps its evidence however old it gets. Without retention, nothing is deleted.

Call prune() directly. It returns the total rows deleted from all three tables and logs each table’s count:

import asyncio
from 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())

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.