Tests / test (>=4,<5) (push) Canceled after 0s
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
622 lines
32 KiB
Markdown
622 lines
32 KiB
Markdown
# Changelog
|
|
|
|
All notable changes to this project will be documented in this file.
|
|
|
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
and this project adheres to [Calendar Versioning](https://calver.org/) (YYYY.MM.DD).
|
|
|
|
## [2026.10.04] - 2026-10-04
|
|
|
|
Bounded payloads, and classifier keys that reach into nested results.
|
|
|
|
### Added
|
|
- `max_payload_bytes` on `InstrumentationMiddleware` and `instrument()`,
|
|
64 KiB by default, `None` for no limit. Arguments and results stored in
|
|
mode `full`, and every event's `attrs` (including `record_llm_call`'s),
|
|
are bounded by it. A larger value is stored as
|
|
`{"_truncated": true, "size": <bytes>, "preview": "<start of its JSON>"}`;
|
|
the preview is 1000 characters (a quarter of the limit when smaller) and
|
|
is taken after redaction. Call payloads reuse the size already measured
|
|
for `args_size` and `result_size`, so the limit adds no encoding; the
|
|
preview encodes only the start of the value. In `attrs`, only the largest
|
|
values are replaced, so the dict keeps its keys. `EmbeddingSink` embeds a
|
|
marker's preview. `fastmcp_feedback.instrumentation.payload` holds the
|
|
marker helpers (`TRUNCATED`, `is_truncated`).
|
|
- `message_keys` on `soft_error_classifier` (default `error`, `message`,
|
|
`detail`, `reason`, as before).
|
|
- Dotted paths in `message_keys` and `error_keys`: `"snapshot.error"` reads
|
|
`result["snapshot"]["error"]`. A key that literally contains a dot is
|
|
matched first; a missing step or a non-dict step counts as absent and
|
|
never raises. Default outputs are unchanged.
|
|
|
|
### Changed
|
|
- Stored payloads and event attrs over 64 KiB become markers. Earlier
|
|
releases stored them whole; `max_payload_bytes=None` keeps that.
|
|
|
|
## [2026.10.02.1] - 2026-10-02
|
|
|
|
The identity resolver's second run is opt-in again for any resolver that
|
|
answers.
|
|
|
|
### Changed
|
|
- The second resolver run after the tool now happens only when the first
|
|
answer was empty (no keys, only None values, or a run that failed or timed
|
|
out) or lacks a key in `late_identity_keys`. In 2026.10.02 a missing
|
|
`user_sub`, `caller_kind` or `client_id` also triggered it, so a resolver
|
|
that never returns one of them (mcspeak's returns no `client_id`) ran twice
|
|
on every kept call. `late_identity_keys` stays empty by default: a resolver
|
|
that answers anything runs once per call unless the server opts in.
|
|
blender-mcp's registration fix still needs
|
|
`late_identity_keys=["bus_client_uuid"]`, as in 2026.10.02.
|
|
|
|
## [2026.10.02] - 2026-10-02
|
|
|
|
A tool that creates its caller's identity records it on its own row again.
|
|
|
|
### Fixed
|
|
- Regression in 2026.10.01.4: with the identity resolver moved before the
|
|
tool, a call whose tool creates the identity the resolver looks up (the
|
|
call that registers a client, or logs in) recorded its own row without
|
|
it. blender-mcp's `register_client` lost `bus_client_uuid`, `bus_role` and
|
|
`parent_uuid` from its row's `identity` this way. When the first answer is
|
|
incomplete, missing `user_sub`, `caller_kind` or `client_id`, or any key in
|
|
the new `late_identity_keys`, the resolver now runs a second time after the
|
|
tool, concurrently with the enricher, for calls that are kept (not for
|
|
sampled-out ones). Its values fill the row's gaps and replace `None`; on
|
|
conflict the first answer wins, so the row matches the events recorded
|
|
during the call, which are not rewritten. Both runs keep `hook_timeout`,
|
|
redaction and never raising. A complete first answer still means one run.
|
|
|
|
### Added
|
|
- `late_identity_keys` on `InstrumentationMiddleware` and `instrument()`:
|
|
identity keys that may only exist once the tool has run. A resolver that
|
|
already returns all three columns before such a tool (blender-mcp's does)
|
|
needs it to get the second run, e.g.
|
|
`late_identity_keys=["bus_client_uuid"]`.
|
|
|
|
## [2026.10.01.4] - 2026-10-01
|
|
|
|
Statistics speak the same enum values as the rest of the API, events carry
|
|
the caller's identity, and importing the package no longer configures
|
|
logging.
|
|
|
|
### Changed
|
|
- `get_feedback_statistics` and `get_database_stats` key `by_type` and
|
|
`by_status` by enum value (`bug`, `in_progress`), the spelling
|
|
`list_feedback` returns and accepts as a filter. They grouped the raw
|
|
columns, which hold the enum NAMES on both SQLite (VARCHAR) and Postgres
|
|
(native enum types `feedbacktype`, `feedbackstatus`), so they returned
|
|
`BUG`, `IN_PROGRESS`. **Code reading the uppercase keys must switch to
|
|
lowercase.** Nothing stored changes. A row holding a value instead of a
|
|
name is counted under the same key; an unknown label is kept as-is.
|
|
- The identity resolver runs once per call **before** the tool, instead of
|
|
after it. The call's record reuses the result (still redacted, still
|
|
limited by `hook_timeout`, still never raising). An async resolver can
|
|
therefore delay the tool by up to `hook_timeout`, and it now also runs for
|
|
ok calls that sampling drops afterwards. It does not run in mode `off` or
|
|
for `exclude_tools`. The enricher still runs after the call.
|
|
- `PACKAGE_INFO` is read from the installed distribution's metadata. It
|
|
listed only the `postgresql` and `dev` extras and a classifier pyproject
|
|
did not have.
|
|
- psycopg2-binary is in the `dev` and `test` extras, so `make test-pg`
|
|
covers the feedback tools on Postgres too.
|
|
|
|
### Fixed
|
|
- `record_event` (and `record_llm_call`) inside a tool call never set
|
|
`user_sub`, `caller_kind` or `client_id`, because identity was resolved
|
|
only after the call. Inside a call they now come from the call's resolved
|
|
identity, matching its row; `client_id` falls back to the FastMCP context's
|
|
as for calls. Values passed explicitly win, and `record_event` takes
|
|
`caller_kind=` and `client_id=` as well as `user_sub=`. Outside a call,
|
|
including a task the call started that records after it returned, nothing
|
|
is filled in.
|
|
- Importing `fastmcp_feedback` called `logging.basicConfig` at INFO when the
|
|
root logger had no handlers, so every host process (the backfill CLI
|
|
included) printed an "initialized" line and had its root logger set up by
|
|
a library. The package now only adds a `NullHandler` to the
|
|
`fastmcp_feedback` logger and logs the version at DEBUG. **Servers that
|
|
relied on the package's INFO or WARNING lines must configure logging
|
|
themselves**; `fastmcp_feedback.setup_logging()` restores the old
|
|
behavior in one line.
|
|
- The backfill CLI logs a summary of its counts at INFO, so `-v` shows
|
|
something on a dry run.
|
|
|
|
### Documentation
|
|
- README: statistics keys, identity resolver timing, a Logging section, the
|
|
two Postgres driver paths (`[postgresql]` for the feedback tools,
|
|
`[instrumentation]` plus asyncpg for the sinks), MySQL marked untested,
|
|
and the measured coverage (95% lines and branches with the Postgres cases
|
|
enabled) in place of "98%".
|
|
- CONTRIBUTING: the example test, the mixin recipe and the commands now
|
|
match the package and were run; `make test`, `make test-pg` and
|
|
`make lint` replace commands that did not pass on this repository.
|
|
|
|
## [2026.10.01.3] - 2026-10-01
|
|
|
|
### Documentation
|
|
- The documentation site is public at https://fastmcp-feedback.supported.systems,
|
|
rewritten against this package with every example executed. The README
|
|
links and the PyPI "Documentation" URL point there instead of the private
|
|
dev address.
|
|
|
|
## [2026.10.01.2] - 2026-10-01
|
|
|
|
The instrumentation imports on a base install, without the database extras.
|
|
|
|
### Fixed
|
|
- `import fastmcp_feedback.instrumentation` failed on `pip install
|
|
fastmcp-feedback` without the `instrumentation` extra: the package imported
|
|
`storage.py` eagerly, which imports `sqlalchemy.ext.asyncio`, and SQLAlchemy
|
|
2.1 raises `ImportError` there when `greenlet` is missing. `instrument(app)`
|
|
with `JsonLinesSink`, `CallbackSink` or `MemorySink`, events, redaction and
|
|
result classification now need only the base dependencies.
|
|
- `DatabaseSink`, `EmbeddingVector`, `build_metadata`, `Embedder`,
|
|
`EmbeddingSink`, `EmbeddingText`, `OpenAIEmbedder`, `extract_texts`,
|
|
`backfill_embeddings` and `backfill_feedback` load on first access. Without
|
|
the extra, accessing one raises `ImportError` naming the missing package and
|
|
the extra to install (`[instrumentation]`, or `[instrumentation,embeddings]`
|
|
for the embedding names), chained to the original error. Every access
|
|
raises, not only the first, although SQLAlchemy itself reports a missing
|
|
`greenlet` only once. Import paths are unchanged.
|
|
- The middleware finds a `DatabaseSink` or `EmbeddingSink` among its sinks
|
|
without importing their modules, so `database_sink`, `embedding_sink`,
|
|
`capture_recent_calls`, `link_feedback`, `feedback_context`, `events_for`,
|
|
`similar` and `similar_feedback` return their usual empty results on a base
|
|
install.
|
|
- `DEFAULT_TABLE_PREFIX`, `DEFAULT_EMBEDDING_DIM`, `EMBEDDING_SOURCES` and
|
|
`DEFAULT_EVENT_TEXT_KEYS` moved to `instrumentation/constants.py` and import
|
|
without the extras; `storage` and `embeddings` still export them.
|
|
|
|
## [2026.10.01.1] - 2026-10-01
|
|
|
|
A result that says it succeeded is no longer flagged for its own error string.
|
|
|
|
### Changed
|
|
- Explicit success flag wins over error keys: when any of the classifier's
|
|
`flag_keys` (`ok`, `success`) is exactly `True`, the `error_keys` check is
|
|
skipped and the string is treated as a warning, so
|
|
`{"ok": True, "error": "deprecated arg"}` is recorded as `ok` again. Only
|
|
that check is skipped: a failing status (`{"success": True, "status":
|
|
"failed"}`), any flag key exactly `False` and `isError` still flag the
|
|
result. Truthy values that are not `True`, such as `1` or `"yes"`, are not
|
|
an explicit success. Results with no flag key are classified as in
|
|
2026.10.01.
|
|
|
|
## [2026.10.01] - 2026-10-01
|
|
|
|
Results that report failure only as an `error` string now count as soft
|
|
errors, and the docs cover joining events to calls.
|
|
|
|
### Changed
|
|
- `soft_error_classifier(..., error_keys=("error",))`: a dict result whose
|
|
value at any of `error_keys` is a non-empty string (after stripping) is a
|
|
soft error, with message `<key>: <string>`, redacted and truncated as
|
|
before. Checked after the status, flag and `isError` checks, so results
|
|
those already flag keep their message and are counted once. None, empty or
|
|
blank strings, numbers, booleans, dicts and lists do not count, so
|
|
`{"error": None}` and `{"error": 0.02}` stay ok. `error_keys=()` turns it
|
|
off. `DEFAULT_RESULT_CLASSIFIER` has it on.
|
|
- **Behavior change:** results such as `{"played": False, "error": "..."}`,
|
|
`{"error": "..."}` and `{"status": "unknown_target", "error": "..."}` were
|
|
recorded as `ok` and are now `soft_error`, so `NOT ok` and soft-error
|
|
counts can rise after upgrading. This includes the package's own
|
|
`list_feedback`, `get_feedback_statistics` and analytics tools, whose
|
|
failure results carry an `error` string and no flag key. Pass
|
|
`result_classifier=soft_error_classifier(error_keys=())` for the old
|
|
behavior.
|
|
|
|
### Docs
|
|
- `record_event` / `events_for`: a `key` used for joining must be unique for
|
|
at least the retention period and across restarts; a per-boot counter
|
|
collides. Use a UUID or a startup token plus a counter.
|
|
- The join from a call to its events through an enricher-written `extra`
|
|
field, for SQLite (`json_extract(c.extra, '$.speech_id') = e.key`) and
|
|
Postgres (`c.extra->>'speech_id' = e.key`).
|
|
- Schema notes: `ffb_tool_calls` has primary key `id` and no `call_id`
|
|
column; `ffb_events.call_id` holds a call's `id`. Ids are UUIDs, so order
|
|
events by `occurred_at`.
|
|
- The soft-error section documents `error_keys`, its default and the
|
|
upgrade note.
|
|
|
|
## [2026.09.30.1] - 2026-09-30
|
|
|
|
Embed what was stored before embedding was switched on, and embed list and
|
|
dict attrs as text rather than as Python reprs.
|
|
|
|
### Added
|
|
- `await backfill_embeddings(embedding_sink, *, sources=None, since=None,
|
|
until=None, limit=None, batch_size=500, dry_run=False)`. Reads the
|
|
`DatabaseSink`'s tables for calls with outcome `error` or `soft_error`
|
|
(and, for rows from before the `outcome` column, outcome NULL with an
|
|
`error_message`), `feedback.submitted` events, `llm.call` events with
|
|
prompt or completion, and other events with any of the sink's
|
|
`event_text_keys`. Each record goes through the live sink's extraction,
|
|
redactor and `max_chars`, so text, `text_hash` and `source_id` match and
|
|
later records dedupe against backfilled rows. Idempotent: a second run
|
|
embeds nothing. Keyset-paged by timestamp and id, `batch_size` rows per
|
|
query; embeds in chunks of the sink's `batch_size`. `limit` embeds at most
|
|
that many texts, oldest first. Returns counts per source type plus
|
|
`skipped` and `errors`; embed failures are counted, never raised. Raises
|
|
only for bad arguments or a missing table. `sources` defaults to the
|
|
sink's.
|
|
- `await backfill_feedback(embedding_sink, items, *, batch_size=500,
|
|
dry_run=False)` for feedback in the host's own tables, as
|
|
`(ref, title, description)` tuples; the same text a `feedback.submitted`
|
|
event with that ref gives.
|
|
- `python -m fastmcp_feedback.instrumentation.backfill --database-url ...
|
|
--embed-base-url ... [--model --dim --prefix --sources --since --until
|
|
--limit --batch-size --max-chars --event-text-keys --create-tables
|
|
--dry-run]`. The API key comes from `FFB_EMBED_API_KEY` or the variable
|
|
named by `--api-key-env`. Prints counts as JSON; exits 1 when any text
|
|
failed, 2 on a setup error.
|
|
|
|
### Changed
|
|
- **Embedded text of list, tuple and dict values.** Lists and tuples are
|
|
embedded one cleaned, non-empty item per line instead of as their repr
|
|
(`"['a', 'b']"`); dicts as `key: value` lines, sorted by key because
|
|
PostgreSQL JSONB does not keep key order and a backfill must rebuild the
|
|
same text. Nested values are flattened the same way, to a depth of 32 and
|
|
about 64k characters before `max_chars` applies. This applies to event
|
|
text keys, feedback title and description, and LLM prompt and completion.
|
|
Existing rows embedded from such values keep their old text; a backfill
|
|
run replaces them, since their text changed.
|
|
|
|
## [2026.09.30] - 2026-09-30
|
|
|
|
Embed feedback, tool errors, event text and (opt-in) LLM text, and search
|
|
them by meaning: "has anyone reported this before?"
|
|
|
|
### Added
|
|
- `EmbeddingSink(embedder, database_sink, *, sources=("feedback",
|
|
"call_error", "event", "llm"), event_text_keys=("error_excerpt", "error",
|
|
"message", "reason"), max_chars=4000, max_queue=1000, batch_size=32,
|
|
timeout=30.0, scan_limit=5000)`. `write` extracts text and puts it on the
|
|
sink's own bounded queue, returning at once, so a slow embedding backend
|
|
never reaches the dispatcher's `sink_timeout` or a tool call. A background
|
|
task embeds in batches and upserts through the `DatabaseSink`'s engine. A
|
|
full queue drops and counts (`dropped`); embedder and database failures are
|
|
logged and counted (`errors`), never raised. An unchanged text (same
|
|
`text_hash`) is not embedded again; a changed one replaces its row. Text is
|
|
redacted with the middleware's `Redactor` (adopted when the sink is passed
|
|
to `instrument`) and truncated before it leaves the process. `aclose()`
|
|
drains for up to `timeout`, then drops and counts the rest.
|
|
- `Embedder` protocol (`model`, `dim`, `async embed(texts)`) and
|
|
`OpenAIEmbedder(base_url, api_key=None, model="mxbai-embed-large", dim=1024,
|
|
batch_size=32, timeout=30.0)` for any OpenAI-compatible `/v1/embeddings`
|
|
endpoint. It uses `httpx`, or `httpx2` under FastMCP 4; no new dependency.
|
|
A vector of the wrong length raises `ValueError`.
|
|
- `await mw.similar(text, *, source_types=None, k=10, min_score=None)` and
|
|
`await mw.similar_feedback(feedback_ref, *, k=10, source_types=None,
|
|
min_score=None)`. Results carry `source_type`, `source_id`, `text`, `score`
|
|
(1 - cosine distance, clamped to [0, 1]) and `created_at`. PostgreSQL ranks
|
|
with `embedding <=> :q` on an HNSW index; other databases compare in Python
|
|
against the newest `scan_limit` rows. `similar_feedback` reuses the stored
|
|
vector and leaves the item itself out. Neither raises; failures log and
|
|
return [].
|
|
- **Feedback flows as an event.** With `instrumentation=`, `submit_feedback`
|
|
records `feedback.submitted` keyed by the feedback id, with `type`, `title`,
|
|
`description` and `submitter`. Hosts with their own feedback tools record
|
|
the same event with their own ref (`bug-7Q2X`).
|
|
- `InstrumentationMiddleware(capture_llm_text=False, max_llm_text_chars=None)`
|
|
and `record_llm_call(..., prompt=None, completion=None)`. Prompt and
|
|
completion are stored (redacted, cut to `8 * max_error_chars`, 4000 by
|
|
default) only when `capture_llm_text=True`.
|
|
- `embeddings` extra: `pgvector>=0.5.0`. Postgres hosts also need `asyncpg`.
|
|
- `build_metadata(prefix, metadata=None, embedding_dim=1024)` and
|
|
`DatabaseSink(embedding_dim=1024)`. `embedding_dim=None` in `build_metadata`
|
|
leaves the embeddings table out.
|
|
|
|
### Changed
|
|
- **New table `{prefix}embeddings`** (`id`, `source_type`, `source_id`,
|
|
`model`, `dim`, `text_hash`, `text`, `created_at`, `embedding`), unique on
|
|
(`source_type`, `source_id`, `model`). `embedding` is pgvector `vector(dim)`
|
|
with an HNSW `vector_cosine_ops` index on PostgreSQL, a JSON float list
|
|
elsewhere. `build_metadata()` now includes it, so Alembic hosts need a
|
|
migration; the README has the SQL, starting with
|
|
`CREATE EXTENSION IF NOT EXISTS vector`. With `create_tables=True` the table
|
|
is created when an `EmbeddingSink` first writes, not at startup, so hosts
|
|
that do not embed need neither pgvector nor the extension. On PostgreSQL
|
|
that step first runs `CREATE EXTENSION IF NOT EXISTS vector`; without the
|
|
privilege it logs a warning, keeps the sink running, and retries at most
|
|
once a minute.
|
|
- `DatabaseSink.prune()` also deletes embeddings older than the cutoff,
|
|
except those of feedback, and returns the total from all three tables.
|
|
- `record_llm_call` now drops `prompt` and `completion` keys passed in
|
|
`attrs` unless `capture_llm_text=True`. Earlier versions stored them.
|
|
|
|
### Documentation
|
|
- README: `DatabaseSink` needs the `instrumentation` extra even with
|
|
SQLAlchemy installed, since SQLAlchemy 2.1 no longer installs `greenlet`.
|
|
|
|
## [2026.09.29.1] - 2026-09-29
|
|
|
|
### Fixed
|
|
- `DatabaseSink(create_tables=True)`: two processes starting at the same
|
|
moment against a database without the tables both ran `create_all`, and one
|
|
failed (SQLite: `table ... already exists`; Postgres: a unique violation on
|
|
`pg_type_typname_nsp_index`). That process dropped its first batch and
|
|
counted a sink error. `ensure_tables()` now waits 50 to 250 ms after a
|
|
database error and runs `create_all` once more, which skips what the other
|
|
process created; the recovery is logged at INFO. A second failure is raised
|
|
as before. Measured on two-writer cold starts: SQLite 9 of 10 runs lost a
|
|
batch before, 0 of 10 after; Postgres 16 12 of 12 before, 0 of 12 after.
|
|
|
|
## [2026.09.29] - 2026-09-29
|
|
|
|
Record things that are not tool calls: background job transitions, LLM
|
|
requests a tool makes, anything with a dotted name.
|
|
|
|
### Added
|
|
- `mw.record_event(kind, *, key=None, attrs=None, user_sub=None,
|
|
call_id=None) -> bool`. Synchronous; never raises, never blocks. `attrs` is
|
|
redacted like call arguments and made JSON-safe (`NaN`/infinities become
|
|
strings, since Postgres JSONB rejects them). Returns False when the event
|
|
was not recorded: mode `off`, an empty or over-128-character `kind`, a `key`
|
|
over 255 characters, non-mapping `attrs`, a full queue, or no event loop.
|
|
Events share the call records' queue and drop-and-count policy.
|
|
- **Linking to the running call.** The middleware now picks a call's record id
|
|
before the tool runs and exposes it through a ContextVar, so an event
|
|
recorded inside a tool gets that call's id as `call_id` (and its session),
|
|
matching the stored `ffb_tool_calls` row. Outside a call, `call_id` is None;
|
|
a task a tool started that records after the tool returned counts as
|
|
outside.
|
|
- **Thread safety.** `record_event` works from any thread: off the server's
|
|
loop, the event is handed over with `call_soon_threadsafe`. The loop is
|
|
known from the first tool call (the dispatcher now binds to it as each call
|
|
starts), so sync tools running in FastMCP's worker threads link too. Before
|
|
any call or record, a thread with no loop of its own gets False and a
|
|
counted drop.
|
|
- `mw.record_llm_call(*, model, provider=None, duration_ms=None,
|
|
input_tokens=None, output_tokens=None, ok=None, error=None, key=None,
|
|
attrs=None) -> bool` records kind `llm.call` with plain attribute names:
|
|
`model`, `provider`, `duration_ms`, `input_tokens`, `output_tokens`, `ok`
|
|
(defaults to `error is None`), `error` (redacted, truncated to
|
|
`max_error_chars`) and `error_type` for exceptions. The README maps them to
|
|
OTel GenAI names.
|
|
- `await mw.events_for(key, *, kinds=None, limit=1000)`: a key's events, oldest
|
|
first (the newest `limit`), from the `DatabaseSink` plus events still in the
|
|
queue, deduplicated by id, `occurred_at` in UTC. Without a `DatabaseSink` it
|
|
answers from memory (`recent_events=2000` on the middleware).
|
|
- `EventRecord`, exported from `fastmcp_feedback.instrumentation`.
|
|
`MemorySink` has `.calls` and `.events`.
|
|
- Exclusion and sampling still apply to tool calls only. A sampled tool's call
|
|
that recorded an event is always kept (no `sample_rate`), so the event's
|
|
`call_id` has a row.
|
|
|
|
### Changed
|
|
- Sinks receive mixed batches of `ToolCallRecord` and `EventRecord` once
|
|
events are recorded. `record.record_type` is `tool_call` or `event`. A
|
|
`CallbackSink` callback that assumes every record is a call needs a check.
|
|
- `JsonLinesSink` lines carry `"record": "tool_call"` or `"record": "event"`
|
|
(a new field; existing fields unchanged).
|
|
- `DatabaseSink.write` stores calls and events in separate transactions,
|
|
calls first, so a database without the events table still stores calls.
|
|
- `DatabaseSink.prune()` also deletes events whose `occurred_at` is before
|
|
the cutoff, in `prune_batch` batches, and **returns the total rows deleted
|
|
from both tables**; each table's count is logged. It skips events on a
|
|
database that has no events table.
|
|
|
|
### Migration
|
|
- New table `{prefix}events` (`ffb_events` by default): `id`, `occurred_at`,
|
|
`kind`, `key`, `call_id`, `session_id`, `user_sub`, `caller_kind`,
|
|
`client_id`, `server_version`, `attrs` (JSON/JSONB), indexed on
|
|
`occurred_at`, `kind`, `key`, `call_id` and `user_sub`. Hosts that manage the
|
|
schema with Alembic need a migration creating it (`build_metadata()`
|
|
includes it). `create_tables=True` creates it on first use. Until it exists,
|
|
event writes fail and are dropped (logged); calls are unaffected. No change
|
|
to existing tables.
|
|
|
|
## [2026.09.28] - 2026-09-28
|
|
|
|
Keep the tool-call table small: leave out or sample busy tools, and prune old
|
|
rows.
|
|
|
|
### Added
|
|
- `InstrumentationMiddleware(..., exclude_tools=...)`: tools that are never
|
|
recorded and never kept for feedback links. The calls themselves are
|
|
untouched.
|
|
- `InstrumentationMiddleware(..., sample_rates={"tool": 0.01})`: record that
|
|
fraction of a tool's `ok` calls. **Failures are always recorded**: calls with
|
|
outcome `error` or `soft_error` are kept whatever the rate. Rates outside
|
|
(0, 1] raise `ValueError`. Calls sampled out skip the hooks and redaction and
|
|
are not kept for feedback links. `random=` takes a `() -> float` for tests.
|
|
- Records have a `sample_rate`: the configured rate on sampled-in `ok` rows,
|
|
None on everything recorded unconditionally (including failures of sampled
|
|
tools). `SUM(1.0 / COALESCE(sample_rate, 1))` estimates the true call count.
|
|
- `DatabaseSink(..., retention=timedelta(days=30), prune_interval=...,
|
|
prune_batch=5000)`. With `retention` set, the background writer prunes after
|
|
an insert at most once per `prune_interval` (one hour by default). A failing
|
|
prune is logged and never stops the insert.
|
|
- `DatabaseSink.prune(older_than=None) -> int` deletes older rows in batches
|
|
of `prune_batch`, one transaction each, on SQLite and Postgres. **Calls
|
|
linked to feedback are never pruned**, and links are never deleted.
|
|
|
|
### Migration
|
|
- New nullable `sample_rate` column (`Float`) on `{prefix}tool_calls`. Hosts
|
|
that manage the schema with Alembic need a migration adding it.
|
|
`create_tables=True` does not alter a table that already exists, so add it by
|
|
hand there (`ALTER TABLE ffb_tool_calls ADD COLUMN sample_rate FLOAT`). Until
|
|
the column exists, `DatabaseSink` writes fail and those records are dropped
|
|
(logged). Hosts still on 2026.09.27.3 or earlier also need the `outcome`
|
|
column from 2026.09.27.4.
|
|
|
|
## [2026.09.27.4] - 2026-09-27
|
|
|
|
Count errors that tools return as data instead of raising.
|
|
|
|
### Added
|
|
- Records have an `outcome`: `ok`, `soft_error` (the tool returned a result
|
|
flagged as a failure) or `error` (the tool raised). Soft errors get
|
|
`error_type="SoftError"` and an `error_message` built from the result, such
|
|
as `status=failed: disk full`, redacted and truncated to `max_error_chars`.
|
|
- `soft_error_classifier(statuses=..., status_keys=..., flag_keys=...,
|
|
extra_statuses=...)` builds a classifier that flags dict results where `ok`
|
|
or `success` is `False`, where `status` or `state` is `error`, `failed` or
|
|
`failure`, or that the tool marked `isError` without raising.
|
|
`DEFAULT_RESULT_CLASSIFIER` is `soft_error_classifier()`.
|
|
- `InstrumentationMiddleware(..., result_classifier=...)` takes any
|
|
`(tool, result) -> str | None`; `None` turns classification off. A
|
|
classifier that raises is logged and the call is recorded as `ok`.
|
|
- `feedback_context()` rows include `outcome`.
|
|
|
|
### Changed
|
|
- `ok` is now `outcome == "ok"`, so it is also false for soft errors. Existing
|
|
`WHERE NOT ok` queries and error rates will rise; filter on
|
|
`outcome = 'error'` for exceptions only. `CallLink.ok` follows.
|
|
- The package's own feedback tools answer `{"success": False, ...}` on failure,
|
|
so those calls are now recorded as soft errors.
|
|
|
|
### Migration
|
|
- New nullable, indexed `outcome` column (`String(16)`) on
|
|
`{prefix}tool_calls`. Hosts that manage the schema with Alembic need a
|
|
migration adding it. `create_tables=True` does not alter a table that
|
|
already exists, so add the column by hand there
|
|
(`ALTER TABLE ffb_tool_calls ADD COLUMN outcome VARCHAR(16)` plus an index).
|
|
Until the column exists, `DatabaseSink` writes fail and those records are
|
|
dropped (logged). Rows from earlier versions keep NULL.
|
|
|
|
## [2026.09.27.3] - 2026-09-27
|
|
|
|
Link a feedback item to the tool calls that preceded it.
|
|
|
|
### Added
|
|
- `InstrumentationMiddleware.capture_recent_calls(ctx, n=20)` returns the
|
|
calls that led up to now. It matches the same client session first
|
|
(`session` rule), then fills remaining slots with calls by the same
|
|
`user_sub` inside `window` (`user_window` rule, 15 minutes by default), and
|
|
each link records which rule matched.
|
|
- Calls are kept in an in-memory buffer as they happen, so calls still
|
|
waiting in the sink queue count. With a `DatabaseSink` the lookup falls back
|
|
to the database, which covers calls from other workers and from before a
|
|
restart.
|
|
- `link_feedback(ref, links)` stores links against a feedback id of any
|
|
format (`bug-7Q2X`, `42`) in a new `{prefix}feedback_call_links` table.
|
|
`build_metadata()` includes it; hosts that manage the schema with Alembic
|
|
need a new migration.
|
|
- `feedback_context(ref)` returns the linked calls in order with tool,
|
|
outcome, error type and matching rule, filling in calls not yet written
|
|
from memory.
|
|
- `add_feedback_tools(..., instrumentation=mw)`: `submit_feedback` links the
|
|
preceding calls automatically and returns `linked_calls` in its response.
|
|
- `session_key(ctx)` gives a per-client key that stays stable across calls:
|
|
the HTTP `mcp-session-id` header when present, then one key per process for
|
|
stdio, then `ctx.session_id`.
|
|
|
|
### Fixed
|
|
- With `create_tables=True`, the background writer and `link_feedback` could
|
|
both run `create_all` at once and fail with "table already exists". Table
|
|
creation is now serialized by a lock.
|
|
|
|
### Note
|
|
- Clients on MCP protocol 2026-07-28 (Claude Code, FastMCP 4's `Client`)
|
|
open no HTTP sessions, so their calls link by user only. HTTP servers should
|
|
configure an `identity_resolver`. Stdio servers link by session with every
|
|
client.
|
|
|
|
## [2026.09.27.2] - 2026-09-27
|
|
|
|
### Fixed
|
|
- `DatabaseSink` stored missing `args`/`result` (every `meta` mode row) as the
|
|
JSON literal `null`, so `WHERE args IS NULL` matched nothing. They are now
|
|
SQL `NULL`. No schema change; rows written by 2026.09.27.1 keep JSON `null`
|
|
and can be backfilled with
|
|
`UPDATE ffb_tool_calls SET args = NULL WHERE args = 'null'::jsonb` (same for
|
|
`result`). Reported from production by blender-mcp.
|
|
|
|
## [2026.09.27.1] - 2026-09-27
|
|
|
|
Per-call instrumentation for any FastMCP server.
|
|
|
|
### Added
|
|
- `fastmcp_feedback.instrumentation`: `InstrumentationMiddleware` and
|
|
`instrument(app)` record every tool call on the server (tool, timing,
|
|
outcome, original error type, session and request ids, argument and result
|
|
sizes). Modes `off`, `meta` (default) and `full`, set by argument or
|
|
`FEEDBACK_INSTRUMENTATION_MODE`; `meta_only_tools` keeps chosen tools at
|
|
`meta`.
|
|
- Recording never slows or breaks a call: records reach sinks from a
|
|
background task through a bounded queue and are dropped (and counted) when it
|
|
is full. Sink and hook errors are logged, never raised; async hooks are cut
|
|
off after `hook_timeout`.
|
|
- `Redactor`: secrets removed from arguments, results, error text and hook
|
|
output, by key at any depth and by value pattern (JWT, bearer, `sk-`,
|
|
GitHub, Slack, AWS, PyPI and `bmcp_` tokens, `password=...` in text).
|
|
- Sinks: `JsonLinesSink` (stderr), `CallbackSink`, `MemorySink`, and
|
|
`DatabaseSink` on async SQLAlchemy. `DatabaseSink` accepts the host's
|
|
`AsyncEngine`, writes to a prefixed `ffb_tool_calls` table, never creates
|
|
tables unless asked, and `build_metadata()` plugs the table into a host's
|
|
Alembic `env.py`.
|
|
- `identity_resolver` and `enricher` hooks fill `user_sub`, `caller_kind`,
|
|
`client_id`, `server_version` and free-form `identity`/`extra` fields.
|
|
- `instrumentation` extra: `sqlalchemy[asyncio]` and `aiosqlite`.
|
|
- CI covers FastMCP 3.x as well as 2.12, 2.14 and 4.x.
|
|
|
|
## [2026.09.27] - 2026-09-27
|
|
|
|
FastMCP 4 support, and a composition bug that affected every earlier release.
|
|
|
|
### Fixed
|
|
- `compose_feedback_services()` composed nothing on FastMCP 2.14. It called
|
|
`import_server()`, which became async in 2.14, without awaiting it, then
|
|
logged success anyway. On FastMCP 4, where `import_server()` no longer
|
|
exists, it raised `AttributeError`. It now uses `mount()`, which is
|
|
synchronous and available on every supported version.
|
|
- The test suite could not start (a pytest-html hook with pytest-html not
|
|
installed), so it had not run since the package was split out. It runs
|
|
again: 407 tests.
|
|
|
|
### Changed
|
|
- Dependency is now `fastmcp>=2.12.2,<5`. Earlier releases had no ceiling,
|
|
so a fresh install picked up FastMCP 4 untested.
|
|
- `examples/` moved into this repository and ships in the sdist.
|
|
`create_demo_server()` accepts an optional `database_url`. Examples list
|
|
and call tools through `fastmcp.Client`, which works on 2.x and 4.x.
|
|
|
|
### Added
|
|
- CI (Gitea Actions) runs the suite against FastMCP 2.12.2, 2.14.3 and 4.x
|
|
on every push and weekly, plus a non-blocking job against the newest
|
|
FastMCP with no ceiling.
|
|
- A composition test using real servers instead of mocks.
|
|
|
|
### Documentation
|
|
- README rewritten against the code and every Python example executed.
|
|
Previous docs described things that do not exist: four tools (there are
|
|
five, including `delete_feedback`), `get_feedback_stats` (it is
|
|
`get_feedback_statistics`), `InsightsMixin`, an `enable_insights`
|
|
parameter, feedback types `bug_report`/`feature_request`/`general` (they
|
|
are `bug`/`feature`/`improvement`/`question`), and a
|
|
`new -> reviewing -> planned` workflow (it is
|
|
`open -> in_progress -> resolved -> closed`).
|
|
- The default database is in-memory SQLite (`sqlite:///:memory:`), not
|
|
`sqlite:///feedback.db` as previously documented. Pass `database_url` to
|
|
keep feedback across restarts.
|
|
|
|
## [2026.01.12.1] - 2026-01-12
|
|
|
|
### Fixed
|
|
- `ImportError` on install: `mixins/base.py` did not re-export the names
|
|
the other mixin modules import from it.
|
|
|
|
## [2026.01.12] - 2026-01-12
|
|
|
|
### Added
|
|
- Initial public release
|
|
- Core feedback tools: `submit_feedback`, `list_feedback`, `get_feedback_stats`, `update_feedback_status`
|
|
- `add_feedback_tools()` one-line integration function
|
|
- Modular mixin architecture:
|
|
- `SubmissionMixin` - feedback collection
|
|
- `RetrievalMixin` - listing and statistics
|
|
- `ManagementMixin` - status workflow management
|
|
- `InsightsMixin` - privacy-compliant analytics
|
|
- Multi-database support: SQLite, PostgreSQL, MySQL
|
|
- `create_feedback_server()` for server composition
|
|
- Comprehensive test suite (97%+ coverage)
|
|
- Full type hints and Pydantic validation
|
|
- Documentation site (on a private development address at the time)
|
|
|
|
### Technical Details
|
|
- Built on FastMCP 2.12.2+
|
|
- SQLAlchemy 2.0+ ORM
|
|
- Pydantic 2.11+ validation
|
|
- Python 3.11+ required
|