Release 2026.10.04: payload size limit, dotted classifier keys
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

This commit is contained in:
2026-10-04 14:43:32 -06:00
parent 8bf0801cf6
commit 4206e15293
5 changed files with 86 additions and 3 deletions
+28
View File
@@ -5,6 +5,34 @@ 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
+55
View File
@@ -194,6 +194,42 @@ Set it with `mode=` or the `FEEDBACK_INSTRUMENTATION_MODE` environment variable.
`meta_only_tools={"run_code", "create_token"}` keeps chosen tools at `meta`
even in `full` mode.
### Payload size limit
`max_payload_bytes` (64 KiB by default, `None` for no limit) bounds what a
row stores: the arguments and result in `full` mode, and every event's
`attrs`. A larger value is stored as a marker instead:
```json
{"_truncated": true, "size": 812345, "preview": "{\"frames\": [[0.1, 0.2], ..."}
```
`size` is the value's JSON size in bytes, the same measurement as
`args_size` and `result_size`, which the limit reuses, so it costs no second
encoding. `preview` is the start of the JSON text, 1000 characters (a quarter
of the limit when that is smaller), taken after redaction, so a secret cannot
survive in it. For event `attrs` only the largest values are replaced, until
the whole fits, so small fields such as `ok` keep their place:
```python
import asyncio
from fastmcp_feedback.instrumentation import InstrumentationMiddleware, MemorySink
async def main():
sink = MemorySink()
mw = InstrumentationMiddleware([sink])
mw.record_event("job.finished", key="j1", attrs={"ok": False, "error": "E" * 100_000})
await mw.aclose()
attrs = sink.events[0].attrs
print(attrs["ok"], attrs["error"]["_truncated"], attrs["error"]["size"]) # False True 100002
asyncio.run(main())
```
**Upgrading to 2026.10.04:** before this release payloads and attrs were
stored whole. A row that used to hold more than 64 KiB there now holds the
marker; pass `max_payload_bytes=None` to keep storing everything.
### Redaction
Secrets are removed from arguments, results, error messages and hook output
@@ -311,6 +347,25 @@ instrument(app, result_classifier=soft_error_classifier(error_keys=("failure_rea
instrument(app, result_classifier=soft_error_classifier(error_keys=()))
```
`message_keys` sets where the message comes from (`error`, `message`,
`detail`, `reason` by default), and both `message_keys` and `error_keys`
take dotted paths into nested dicts. A key that literally contains a dot is
tried first; a missing step, or one that is not a dict, counts as absent:
```python
from fastmcp_feedback.instrumentation import soft_error_classifier
# mcardupilot: a wait that returned because the simulator died, cause nested
classify = soft_error_classifier(
status_keys=("status", "state", "reason"),
extra_statuses=("died",),
message_keys=("snapshot.error", "error", "message", "detail", "reason"),
)
died = {"reason": "died", "snapshot": {"error": "SITL exited: segfault"}}
assert classify("wait_until", died) == "reason=died: SITL exited: segfault"
assert classify("wait_until", {"reason": "timeout", "snapshot": {}}) is None
```
Add your own status values, or pass any `(tool, result) -> str | None`:
```python
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "fastmcp-feedback"
version = "2026.10.02.1" # YYYYMMDD.patch versioning - update for each release
version = "2026.10.04" # YYYYMMDD.patch versioning - update for each release
description = "Production-ready feedback collection system for FastMCP servers"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -41,7 +41,7 @@ Quick Start:
import logging
# Version information - CalVer YYYY.MM.DD
__version__ = "2026.10.02.1"
__version__ = "2026.10.04"
__author__ = "Ryan Malloy"
__email__ = "ryan@supported.systems"
Generated
+1 -1
View File
@@ -601,7 +601,7 @@ wheels = [
[[package]]
name = "fastmcp-feedback"
version = "2026.10.2.1"
version = "2026.10.4"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },