Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4206e15293 | ||
|
|
8bf0801cf6 | ||
|
|
72d2845502 |
@@ -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/),
|
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).
|
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
|
## [2026.10.02.1] - 2026-10-02
|
||||||
|
|
||||||
The identity resolver's second run is opt-in again for any resolver that
|
The identity resolver's second run is opt-in again for any resolver that
|
||||||
|
|||||||
@@ -61,4 +61,5 @@ lint:
|
|||||||
uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/_databases.py \
|
uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/_databases.py \
|
||||||
tests/unit/test_test_databases.py tests/unit/test_instrumentation.py \
|
tests/unit/test_test_databases.py tests/unit/test_instrumentation.py \
|
||||||
tests/unit/test_embeddings.py tests/unit/test_backfill.py \
|
tests/unit/test_embeddings.py tests/unit/test_backfill.py \
|
||||||
tests/unit/test_consistency.py tests/unit/test_late_identity.py
|
tests/unit/test_consistency.py tests/unit/test_late_identity.py \
|
||||||
|
tests/unit/test_payload_cap.py tests/unit/test_classifier_paths.py
|
||||||
|
|||||||
@@ -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`
|
`meta_only_tools={"run_code", "create_token"}` keeps chosen tools at `meta`
|
||||||
even in `full` mode.
|
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
|
### Redaction
|
||||||
|
|
||||||
Secrets are removed from arguments, results, error messages and hook output
|
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=()))
|
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`:
|
Add your own status values, or pass any `(tool, result) -> str | None`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "fastmcp-feedback"
|
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"
|
description = "Production-ready feedback collection system for FastMCP servers"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Quick Start:
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Version information - CalVer YYYY.MM.DD
|
# Version information - CalVer YYYY.MM.DD
|
||||||
__version__ = "2026.10.02.1"
|
__version__ = "2026.10.04"
|
||||||
__author__ = "Ryan Malloy"
|
__author__ = "Ryan Malloy"
|
||||||
__email__ = "ryan@supported.systems"
|
__email__ = "ryan@supported.systems"
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,36 @@ MESSAGE_KEYS = ("error", "message", "detail", "reason")
|
|||||||
ERROR_FLAG_KEYS = ("isError", "is_error")
|
ERROR_FLAG_KEYS = ("isError", "is_error")
|
||||||
|
|
||||||
|
|
||||||
def _message(result: dict, depth: int = 0) -> str | None:
|
def lookup(result: dict, key: str) -> Any:
|
||||||
for key in MESSAGE_KEYS:
|
"""``result[key]``, where ``key`` may be a dotted path into nested dicts.
|
||||||
value = result.get(key)
|
|
||||||
|
A key present exactly as written wins, so a literal ``"a.b"`` key is
|
||||||
|
found before ``result["a"]["b"]``. Otherwise ``"snapshot.error"`` walks
|
||||||
|
``result["snapshot"]["error"]``. A missing step, or a step that is not a
|
||||||
|
dict, gives None: the same as the key not being there. Never raises.
|
||||||
|
"""
|
||||||
|
if key in result:
|
||||||
|
return result[key]
|
||||||
|
if "." not in key:
|
||||||
|
return None
|
||||||
|
value: Any = result
|
||||||
|
for part in key.split("."):
|
||||||
|
if not isinstance(value, dict) or part not in value:
|
||||||
|
return None
|
||||||
|
value = value[part]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _message(
|
||||||
|
result: dict, depth: int = 0, keys: tuple[str, ...] = MESSAGE_KEYS
|
||||||
|
) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
value = lookup(result, key)
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
continue
|
continue
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
# {"error": {"code": 404, "message": "..."}}
|
# {"error": {"code": 404, "message": "..."}}
|
||||||
if depth == 0 and (inner := _message(value, 1)):
|
if depth == 0 and (inner := _message(value, 1, keys)):
|
||||||
return inner
|
return inner
|
||||||
continue
|
continue
|
||||||
if isinstance(value, list | tuple):
|
if isinstance(value, list | tuple):
|
||||||
@@ -59,14 +81,17 @@ def soft_error_classifier(
|
|||||||
flag_keys: Iterable[str] = ("ok", "success"),
|
flag_keys: Iterable[str] = ("ok", "success"),
|
||||||
extra_statuses: Iterable[str] = (),
|
extra_statuses: Iterable[str] = (),
|
||||||
error_keys: Iterable[str] = ("error",),
|
error_keys: Iterable[str] = ("error",),
|
||||||
|
message_keys: Iterable[str] = MESSAGE_KEYS,
|
||||||
) -> ResultClassifier:
|
) -> ResultClassifier:
|
||||||
"""Build a classifier for dict results that describe their own failure.
|
"""Build a classifier for dict results that describe their own failure.
|
||||||
|
|
||||||
A dict result is a soft error when any of ``flag_keys`` is exactly False,
|
A dict result is a soft error when any of ``flag_keys`` is exactly False,
|
||||||
when any of ``status_keys`` holds one of ``statuses`` or
|
when any of ``status_keys`` holds one of ``statuses`` or
|
||||||
``extra_statuses`` (case-insensitive), or when the tool marked it
|
``extra_statuses`` (case-insensitive), or when the tool marked it
|
||||||
``isError``. The message is the first of ``error``, ``message``,
|
``isError``. The message is the first of ``message_keys`` present
|
||||||
``detail`` and ``reason`` present, e.g. ``status=failed: disk full``.
|
(``error``, ``message``, ``detail``, ``reason`` by default), e.g.
|
||||||
|
``status=failed: disk full``. A dict found there is searched one level
|
||||||
|
down with the same keys, as in ``{"error": {"message": "..."}}``.
|
||||||
|
|
||||||
Failing those, it is a soft error when any of ``error_keys`` holds a
|
Failing those, it is a soft error when any of ``error_keys`` holds a
|
||||||
non-empty string, as in ``{"played": False, "error": "output stalled"}``,
|
non-empty string, as in ``{"played": False, "error": "output stalled"}``,
|
||||||
@@ -81,6 +106,13 @@ def soft_error_classifier(
|
|||||||
``isError`` still flag it. Truthy non-bools such as ``1`` or ``"yes"``
|
``isError`` still flag it. Truthy non-bools such as ``1`` or ``"yes"``
|
||||||
are not an explicit success.
|
are not an explicit success.
|
||||||
|
|
||||||
|
``message_keys`` and ``error_keys`` accept dotted paths into nested
|
||||||
|
dicts: ``"snapshot.error"`` reads ``result["snapshot"]["error"]``. A key
|
||||||
|
that literally contains a dot is matched first; a missing step or one
|
||||||
|
that is not a dict counts as the key not being there. The message label
|
||||||
|
for an ``error_keys`` hit is the path as written. ``status_keys`` and
|
||||||
|
``flag_keys`` are plain keys.
|
||||||
|
|
||||||
>>> classify = soft_error_classifier(extra_statuses=("unknown_target",))
|
>>> classify = soft_error_classifier(extra_statuses=("unknown_target",))
|
||||||
>>> classify("render", {"status": "unknown_target", "error": "no such object"})
|
>>> classify("render", {"status": "unknown_target", "error": "no such object"})
|
||||||
'status=unknown_target: no such object'
|
'status=unknown_target: no such object'
|
||||||
@@ -88,11 +120,22 @@ def soft_error_classifier(
|
|||||||
'error: output stalled'
|
'error: output stalled'
|
||||||
>>> print(classify("speak", {"ok": True, "error": "deprecated arg"}))
|
>>> print(classify("speak", {"ok": True, "error": "deprecated arg"}))
|
||||||
None
|
None
|
||||||
|
>>> sitl = soft_error_classifier(
|
||||||
|
... status_keys=("status", "state", "reason"),
|
||||||
|
... extra_statuses=("died",),
|
||||||
|
... message_keys=("snapshot.error", "error", "message", "detail", "reason"),
|
||||||
|
... )
|
||||||
|
>>> sitl("wait_until", {"reason": "died", "snapshot": {"error": "SITL exited"}})
|
||||||
|
'reason=died: SITL exited'
|
||||||
"""
|
"""
|
||||||
bad = frozenset(s.lower() for s in (*statuses, *extra_statuses))
|
bad = frozenset(s.lower() for s in (*statuses, *extra_statuses))
|
||||||
status_keys = tuple(status_keys)
|
status_keys = tuple(status_keys)
|
||||||
flag_keys = tuple(flag_keys)
|
flag_keys = tuple(flag_keys)
|
||||||
error_keys = tuple(error_keys)
|
error_keys = tuple(error_keys)
|
||||||
|
message_keys = tuple(message_keys)
|
||||||
|
|
||||||
|
def message(result: dict) -> str | None:
|
||||||
|
return _message(result, 0, message_keys)
|
||||||
|
|
||||||
def classify(tool: str, result: Any) -> str | None:
|
def classify(tool: str, result: Any) -> str | None:
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
@@ -100,18 +143,18 @@ def soft_error_classifier(
|
|||||||
for key in status_keys:
|
for key in status_keys:
|
||||||
value = result.get(key)
|
value = result.get(key)
|
||||||
if isinstance(value, str) and value.lower() in bad:
|
if isinstance(value, str) and value.lower() in bad:
|
||||||
return _describe(f"{key}={value}", _message(result))
|
return _describe(f"{key}={value}", message(result))
|
||||||
for key in flag_keys:
|
for key in flag_keys:
|
||||||
if key in result and result[key] is False:
|
if key in result and result[key] is False:
|
||||||
return _describe(f"{key}=False", _message(result))
|
return _describe(f"{key}=False", message(result))
|
||||||
for key in ERROR_FLAG_KEYS:
|
for key in ERROR_FLAG_KEYS:
|
||||||
if result.get(key) is True:
|
if result.get(key) is True:
|
||||||
detail = _message(result) or _content_text(result.get("content"))
|
detail = message(result) or _content_text(result.get("content"))
|
||||||
return _describe("isError", detail)
|
return _describe("isError", detail)
|
||||||
if any(result.get(key) is True for key in flag_keys):
|
if any(result.get(key) is True for key in flag_keys):
|
||||||
return None
|
return None
|
||||||
for key in error_keys:
|
for key in error_keys:
|
||||||
value = result.get(key)
|
value = lookup(result, key)
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(value, str) and value.strip():
|
||||||
return _describe(key, value.strip())
|
return _describe(key, value.strip())
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy import Float, and_, literal, not_, select, tuple_
|
|||||||
|
|
||||||
from .constants import DEFAULT_EVENT_TEXT_KEYS, EMBEDDING_SOURCES
|
from .constants import DEFAULT_EVENT_TEXT_KEYS, EMBEDDING_SOURCES
|
||||||
from .events import EventRecord
|
from .events import EventRecord
|
||||||
|
from .payload import is_truncated
|
||||||
from .record import ToolCallRecord
|
from .record import ToolCallRecord
|
||||||
from .redaction import Redactor
|
from .redaction import Redactor
|
||||||
from .sinks import Record
|
from .sinks import Record
|
||||||
@@ -158,6 +159,10 @@ def _clean(value: Any, depth: int = 0) -> str | None:
|
|||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
if is_truncated(value):
|
||||||
|
# A value over the middleware's max_payload_bytes: its start is all
|
||||||
|
# that was kept.
|
||||||
|
value = value.get("preview")
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
text = value
|
text = value
|
||||||
elif isinstance(value, list | tuple | dict):
|
elif isinstance(value, list | tuple | dict):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import contextvars
|
import contextvars
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random as _random
|
import random as _random
|
||||||
@@ -35,6 +34,7 @@ from .events import (
|
|||||||
EventRecord,
|
EventRecord,
|
||||||
jsonable,
|
jsonable,
|
||||||
)
|
)
|
||||||
|
from .payload import DEFAULT_MAX_PAYLOAD_BYTES, cap, cap_attrs, json_size
|
||||||
from .record import ToolCallRecord
|
from .record import ToolCallRecord
|
||||||
from .redaction import Redactor
|
from .redaction import Redactor
|
||||||
from .sinks import JsonLinesSink, Sink
|
from .sinks import JsonLinesSink, Sink
|
||||||
@@ -114,13 +114,6 @@ def _result_payload(result: Any) -> Any:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _json_size(value: Any) -> int | None:
|
|
||||||
try:
|
|
||||||
return len(json.dumps(value, default=str).encode())
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class InstrumentationMiddleware(Middleware):
|
class InstrumentationMiddleware(Middleware):
|
||||||
"""Record every tool call without ever slowing or breaking it.
|
"""Record every tool call without ever slowing or breaking it.
|
||||||
|
|
||||||
@@ -184,6 +177,11 @@ class InstrumentationMiddleware(Middleware):
|
|||||||
``prompt``/``completion`` keys passed in its ``attrs``.
|
``prompt``/``completion`` keys passed in its ``attrs``.
|
||||||
max_llm_text_chars: longest prompt or completion kept. Defaults to
|
max_llm_text_chars: longest prompt or completion kept. Defaults to
|
||||||
``8 * max_error_chars`` (4000).
|
``8 * max_error_chars`` (4000).
|
||||||
|
max_payload_bytes: largest stored arguments, result (mode ``full``)
|
||||||
|
or event ``attrs``, in bytes of JSON; None for no limit. Larger
|
||||||
|
ones are stored as ``{"_truncated": true, "size": ..., "preview":
|
||||||
|
...}`` markers, after redaction; for attrs, only the largest
|
||||||
|
values are replaced. See ``fastmcp_feedback.instrumentation.payload``.
|
||||||
|
|
||||||
Exclusion and sampling apply to tool calls only; events are always
|
Exclusion and sampling apply to tool calls only; events are always
|
||||||
recorded. A sampled tool's call is kept whenever an event was recorded
|
recorded. A sampled tool's call is kept whenever an event was recorded
|
||||||
@@ -215,6 +213,7 @@ class InstrumentationMiddleware(Middleware):
|
|||||||
recent_events: int = 2000,
|
recent_events: int = 2000,
|
||||||
capture_llm_text: bool = False,
|
capture_llm_text: bool = False,
|
||||||
max_llm_text_chars: int | None = None,
|
max_llm_text_chars: int | None = None,
|
||||||
|
max_payload_bytes: int | None = DEFAULT_MAX_PAYLOAD_BYTES,
|
||||||
):
|
):
|
||||||
mode = (mode or os.getenv(MODE_ENV_VAR) or "meta").lower()
|
mode = (mode or os.getenv(MODE_ENV_VAR) or "meta").lower()
|
||||||
if mode not in MODES:
|
if mode not in MODES:
|
||||||
@@ -247,6 +246,16 @@ class InstrumentationMiddleware(Middleware):
|
|||||||
contextvars.ContextVar(f"ffb_call_scope_{id(self):x}", default=None)
|
contextvars.ContextVar(f"ffb_call_scope_{id(self):x}", default=None)
|
||||||
)
|
)
|
||||||
self.capture_llm_text = capture_llm_text
|
self.capture_llm_text = capture_llm_text
|
||||||
|
if max_payload_bytes is not None and (
|
||||||
|
isinstance(max_payload_bytes, bool)
|
||||||
|
or not isinstance(max_payload_bytes, int)
|
||||||
|
or max_payload_bytes <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"max_payload_bytes must be a positive int or None, "
|
||||||
|
f"got {max_payload_bytes!r}"
|
||||||
|
)
|
||||||
|
self.max_payload_bytes = max_payload_bytes
|
||||||
self.max_llm_text_chars = (
|
self.max_llm_text_chars = (
|
||||||
max_llm_text_chars
|
max_llm_text_chars
|
||||||
if max_llm_text_chars is not None
|
if max_llm_text_chars is not None
|
||||||
@@ -453,6 +462,9 @@ class InstrumentationMiddleware(Middleware):
|
|||||||
self.redactor.redact_text(soft)[: self.max_error_chars] or None
|
self.redactor.redact_text(soft)[: self.max_error_chars] or None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Measured once: the size columns and the payload limit share it.
|
||||||
|
args_size = json_size(args)
|
||||||
|
result_size = json_size(payload) if payload is not None else None
|
||||||
return ToolCallRecord(
|
return ToolCallRecord(
|
||||||
tool=tool,
|
tool=tool,
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
@@ -469,10 +481,14 @@ class InstrumentationMiddleware(Middleware):
|
|||||||
caller_kind=columns.get("caller_kind"),
|
caller_kind=columns.get("caller_kind"),
|
||||||
identity=identity,
|
identity=identity,
|
||||||
server_version=server_version,
|
server_version=server_version,
|
||||||
args_size=_json_size(args),
|
args_size=args_size,
|
||||||
result_size=_json_size(payload) if payload is not None else None,
|
result_size=result_size,
|
||||||
args=self.redactor.redact(args) if full else None,
|
args=cap(self.redactor.redact(args), args_size, self.max_payload_bytes)
|
||||||
result=self.redactor.redact(payload)
|
if full
|
||||||
|
else None,
|
||||||
|
result=cap(
|
||||||
|
self.redactor.redact(payload), result_size, self.max_payload_bytes
|
||||||
|
)
|
||||||
if full and payload is not None
|
if full and payload is not None
|
||||||
else None,
|
else None,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
@@ -550,7 +566,10 @@ class InstrumentationMiddleware(Middleware):
|
|||||||
caller_kind=None if caller_kind is None else str(caller_kind),
|
caller_kind=None if caller_kind is None else str(caller_kind),
|
||||||
client_id=None if client_id is None else str(client_id),
|
client_id=None if client_id is None else str(client_id),
|
||||||
server_version=self.server_version,
|
server_version=self.server_version,
|
||||||
attrs=jsonable(self.redactor.redact(dict(attrs or {}))),
|
attrs=cap_attrs(
|
||||||
|
jsonable(self.redactor.redact(dict(attrs or {}))),
|
||||||
|
self.max_payload_bytes,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return self.dispatcher.submit_threadsafe(record, self.recent_events.append)
|
return self.dispatcher.submit_threadsafe(record, self.recent_events.append)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Size limits for stored payloads: call arguments and results, event attrs.
|
||||||
|
|
||||||
|
A value whose JSON encoding is larger than the limit is stored as a marker:
|
||||||
|
|
||||||
|
{"_truncated": true, "size": 812345, "preview": "{\"frames\": [[0.1, ..."}
|
||||||
|
|
||||||
|
``size`` is the value's encoded size in bytes, measured as for ``args_size``
|
||||||
|
(``json.dumps(value, default=str)``, non-ASCII escaped). ``preview`` is the
|
||||||
|
start of its JSON text, ``preview_chars(limit)`` characters: ``PREVIEW_CHARS``
|
||||||
|
(1000) under the default limit, a quarter of the limit under a smaller one,
|
||||||
|
so the marker itself fits. It is taken from the value after redaction, so a
|
||||||
|
secret cannot reach it. The keys and their meaning are stable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
TRUNCATED = "_truncated"
|
||||||
|
PREVIEW_CHARS = 1000
|
||||||
|
DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
# Incremental, so a preview of a large value encodes only its start.
|
||||||
|
_ENCODER = json.JSONEncoder(default=str, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def json_size(value: Any) -> int | None:
|
||||||
|
"""Bytes of ``value`` as JSON, or None when it cannot be encoded."""
|
||||||
|
try:
|
||||||
|
return len(json.dumps(value, default=str).encode())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_truncated(value: Any) -> bool:
|
||||||
|
"""Whether ``value`` is a marker left by the size limit."""
|
||||||
|
return isinstance(value, dict) and value.get(TRUNCATED) is True
|
||||||
|
|
||||||
|
|
||||||
|
def preview(value: Any, chars: int = PREVIEW_CHARS) -> str:
|
||||||
|
"""The first ``chars`` characters of ``value``'s JSON text."""
|
||||||
|
if chars <= 0:
|
||||||
|
return ""
|
||||||
|
parts: list[str] = []
|
||||||
|
length = 0
|
||||||
|
try:
|
||||||
|
for chunk in _ENCODER.iterencode(value):
|
||||||
|
parts.append(chunk)
|
||||||
|
length += len(chunk)
|
||||||
|
if length >= chars:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return "".join(parts)[:chars]
|
||||||
|
|
||||||
|
|
||||||
|
def preview_chars(limit: int) -> int:
|
||||||
|
"""How much of a value's JSON text a marker keeps under ``limit``."""
|
||||||
|
return min(PREVIEW_CHARS, limit // 4)
|
||||||
|
|
||||||
|
|
||||||
|
def marker(value: Any, size: int | None, limit: int) -> dict[str, Any]:
|
||||||
|
"""The stand-in stored for a ``value`` over ``limit`` bytes."""
|
||||||
|
return {
|
||||||
|
TRUNCATED: True,
|
||||||
|
"size": size,
|
||||||
|
"preview": preview(value, preview_chars(limit)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cap(value: Any, size: int | None, limit: int | None) -> Any:
|
||||||
|
"""``value``, or its marker when ``size`` is over ``limit``.
|
||||||
|
|
||||||
|
``size`` is passed in because the caller has usually measured it already
|
||||||
|
(``args_size``, ``result_size``), so this costs no second encoding.
|
||||||
|
"""
|
||||||
|
if limit is None or size is None or size <= limit:
|
||||||
|
return value
|
||||||
|
return marker(value, size, limit)
|
||||||
|
|
||||||
|
|
||||||
|
def cap_attrs(attrs: dict[str, Any], limit: int | None) -> dict[str, Any]:
|
||||||
|
"""Event attrs within ``limit`` bytes, keeping the dict's keys.
|
||||||
|
|
||||||
|
The largest values are replaced by markers, one at a time, until the
|
||||||
|
whole fits, so small fields (``ok``, ``phase``, ``model``) stay readable.
|
||||||
|
If markers alone cannot get it under the limit (very many keys, or a
|
||||||
|
limit of a few dozen bytes), the whole dict becomes one marker, which
|
||||||
|
under such a tiny limit can itself be larger than the limit.
|
||||||
|
"""
|
||||||
|
if limit is None or not attrs:
|
||||||
|
return attrs
|
||||||
|
total = json_size(attrs)
|
||||||
|
if total is None or total <= limit:
|
||||||
|
return attrs
|
||||||
|
sizes = {key: json_size(value) or 0 for key, value in attrs.items()}
|
||||||
|
out = dict(attrs)
|
||||||
|
for key in sorted(sizes, key=sizes.__getitem__, reverse=True):
|
||||||
|
if total <= limit:
|
||||||
|
break
|
||||||
|
stand_in = marker(attrs[key], sizes[key], limit)
|
||||||
|
smaller = sizes[key] - (json_size(stand_in) or 0)
|
||||||
|
if smaller <= 0:
|
||||||
|
continue
|
||||||
|
out[key] = stand_in
|
||||||
|
total -= smaller
|
||||||
|
if total > limit:
|
||||||
|
return marker(attrs, json_size(attrs), limit)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""soft_error_classifier: message_keys and dotted paths into nested results."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastmcp_feedback.instrumentation import soft_error_classifier
|
||||||
|
from fastmcp_feedback.instrumentation.classify import MESSAGE_KEYS, lookup
|
||||||
|
|
||||||
|
# mcardupilot: a wait that returned because the SITL died, the cause nested.
|
||||||
|
SITL = soft_error_classifier(
|
||||||
|
status_keys=("status", "state", "reason"),
|
||||||
|
extra_statuses=("died",),
|
||||||
|
message_keys=("snapshot.error", "error", "message", "detail", "reason"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcardupilot:
|
||||||
|
def test_died_reads_the_nested_cause(self):
|
||||||
|
result = {"reason": "died", "snapshot": {"error": "SITL exited: segfault"}}
|
||||||
|
assert SITL("wait_until", result) == "reason=died: SITL exited: segfault"
|
||||||
|
|
||||||
|
def test_timeout_stays_ok(self):
|
||||||
|
result = {"reason": "timeout", "snapshot": {"error": None, "alt": 12.0}}
|
||||||
|
assert SITL("wait_until", result) is None
|
||||||
|
|
||||||
|
def test_reached_stays_ok(self):
|
||||||
|
assert SITL("wait_until", {"reason": "reached", "snapshot": {}}) is None
|
||||||
|
|
||||||
|
def test_died_without_a_cause_falls_back(self):
|
||||||
|
assert SITL("wait_until", {"reason": "died"}) == "reason=died: died"
|
||||||
|
|
||||||
|
def test_dead_session_state(self):
|
||||||
|
result = {"state": "died", "error": "lost heartbeat"}
|
||||||
|
assert SITL("snapshot", result) == "state=died: lost heartbeat"
|
||||||
|
|
||||||
|
def test_default_message_keys_cannot_reach_it(self):
|
||||||
|
plain = soft_error_classifier(
|
||||||
|
status_keys=("status", "state", "reason"), extra_statuses=("died",)
|
||||||
|
)
|
||||||
|
result = {"reason": "died", "snapshot": {"error": "SITL exited: segfault"}}
|
||||||
|
assert plain("wait_until", result) == "reason=died: died"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLookup:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"result",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"snapshot": None},
|
||||||
|
{"snapshot": "a string"},
|
||||||
|
{"snapshot": ["error"]},
|
||||||
|
{"snapshot": 3},
|
||||||
|
{"snapshot": {}},
|
||||||
|
{"snapshot": {"other": 1}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_missing_or_non_dict_step_is_absent(self, result):
|
||||||
|
assert lookup(result, "snapshot.error") is None
|
||||||
|
|
||||||
|
def test_deep_path(self):
|
||||||
|
assert lookup({"a": {"b": {"c": "x"}}}, "a.b.c") == "x"
|
||||||
|
|
||||||
|
def test_literal_dotted_key_wins(self):
|
||||||
|
result = {"snapshot.error": "literal", "snapshot": {"error": "nested"}}
|
||||||
|
assert lookup(result, "snapshot.error") == "literal"
|
||||||
|
|
||||||
|
def test_plain_key(self):
|
||||||
|
assert lookup({"error": "x"}, "error") == "x"
|
||||||
|
assert lookup({"error": None}, "error") is None
|
||||||
|
|
||||||
|
def test_odd_paths_never_raise(self):
|
||||||
|
for key in ("", ".", "a.", ".a", "a..b"):
|
||||||
|
lookup({"a": {"": {"b": 1}}}, key)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptions:
|
||||||
|
def test_default_message_keys(self):
|
||||||
|
assert MESSAGE_KEYS == ("error", "message", "detail", "reason")
|
||||||
|
|
||||||
|
def test_message_keys_order(self):
|
||||||
|
classify = soft_error_classifier(message_keys=("detail", "error"))
|
||||||
|
result = {"status": "failed", "error": "e", "detail": "d"}
|
||||||
|
assert classify("t", result) == "status=failed: d"
|
||||||
|
|
||||||
|
def test_dotted_error_keys(self):
|
||||||
|
classify = soft_error_classifier(error_keys=("result.error",))
|
||||||
|
assert classify("t", {"result": {"error": " broke "}}) == "result.error: broke"
|
||||||
|
assert classify("t", {"result": {"error": ""}}) is None
|
||||||
|
assert classify("t", {"result": "error"}) is None
|
||||||
|
|
||||||
|
def test_dotted_error_keys_respect_explicit_success(self):
|
||||||
|
classify = soft_error_classifier(error_keys=("result.error",))
|
||||||
|
assert classify("t", {"ok": True, "result": {"error": "warn"}}) is None
|
||||||
|
|
||||||
|
def test_nested_dict_message_searched_with_same_keys(self):
|
||||||
|
classify = soft_error_classifier(message_keys=("problem",))
|
||||||
|
result = {"status": "failed", "problem": {"problem": "inner"}}
|
||||||
|
assert classify("t", result) == "status=failed: inner"
|
||||||
|
|
||||||
|
def test_is_error_uses_message_keys(self):
|
||||||
|
classify = soft_error_classifier(message_keys=("why",))
|
||||||
|
assert classify("t", {"isError": True, "why": "nope"}) == "isError: nope"
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""max_payload_bytes: stored arguments, results and event attrs are bounded."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp import Client, FastMCP
|
||||||
|
|
||||||
|
from fastmcp_feedback.instrumentation import (
|
||||||
|
REDACTED,
|
||||||
|
DatabaseSink,
|
||||||
|
EventRecord,
|
||||||
|
MemorySink,
|
||||||
|
instrument,
|
||||||
|
)
|
||||||
|
from fastmcp_feedback.instrumentation import payload as payload_mod
|
||||||
|
from fastmcp_feedback.instrumentation.embeddings import extract_texts
|
||||||
|
from fastmcp_feedback.instrumentation.payload import (
|
||||||
|
DEFAULT_MAX_PAYLOAD_BYTES,
|
||||||
|
PREVIEW_CHARS,
|
||||||
|
TRUNCATED,
|
||||||
|
cap_attrs,
|
||||||
|
is_truncated,
|
||||||
|
json_size,
|
||||||
|
preview_chars,
|
||||||
|
)
|
||||||
|
|
||||||
|
SECRET = "sk-" + "a1b2c3d4e5" * 4 # matches the default sk- pattern
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(**kwargs):
|
||||||
|
sink = MemorySink()
|
||||||
|
app = FastMCP("cap")
|
||||||
|
holder = {}
|
||||||
|
|
||||||
|
@app.tool
|
||||||
|
def echo(blob: str) -> dict:
|
||||||
|
return {"blob": blob}
|
||||||
|
|
||||||
|
@app.tool
|
||||||
|
def big(n: int) -> dict:
|
||||||
|
return {"rows": ["x" * 100] * n}
|
||||||
|
|
||||||
|
@app.tool
|
||||||
|
def report(error: str) -> str:
|
||||||
|
holder["mw"].record_event(
|
||||||
|
"job.finished", key="j1", attrs={"ok": False, "error": error}
|
||||||
|
)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
kwargs.setdefault("mode", "full")
|
||||||
|
mw = instrument(app, [sink], **kwargs)
|
||||||
|
holder["mw"] = mw
|
||||||
|
return app, sink, mw
|
||||||
|
|
||||||
|
|
||||||
|
async def call(app, mw, tool, args):
|
||||||
|
async with Client(app) as client:
|
||||||
|
await client.call_tool(tool, args)
|
||||||
|
await mw.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallPayloads:
|
||||||
|
async def test_default_is_64_kib(self):
|
||||||
|
app, sink, mw = make_app()
|
||||||
|
assert mw.max_payload_bytes == DEFAULT_MAX_PAYLOAD_BYTES == 65536
|
||||||
|
|
||||||
|
async def test_under_the_cap_stored_whole(self):
|
||||||
|
app, sink, mw = make_app()
|
||||||
|
await call(app, mw, "echo", {"blob": "y" * 1000})
|
||||||
|
[rec] = sink.calls
|
||||||
|
assert rec.args == {"blob": "y" * 1000}
|
||||||
|
assert rec.result == {"blob": "y" * 1000}
|
||||||
|
|
||||||
|
async def test_over_the_cap_becomes_a_marker(self):
|
||||||
|
app, sink, mw = make_app()
|
||||||
|
await call(app, mw, "big", {"n": 2000})
|
||||||
|
[rec] = sink.calls
|
||||||
|
assert rec.args == {"n": 2000}
|
||||||
|
assert rec.result_size > 65536
|
||||||
|
assert set(rec.result) == {TRUNCATED, "size", "preview"}
|
||||||
|
assert rec.result[TRUNCATED] is True
|
||||||
|
assert rec.result["size"] == rec.result_size
|
||||||
|
assert len(rec.result["preview"]) == PREVIEW_CHARS
|
||||||
|
assert rec.result["preview"].startswith('{"rows": ["xxxx')
|
||||||
|
json.dumps(rec.result) # JSON-safe
|
||||||
|
|
||||||
|
async def test_args_over_the_cap(self):
|
||||||
|
app, sink, mw = make_app(max_payload_bytes=500)
|
||||||
|
await call(app, mw, "echo", {"blob": "z" * 2000})
|
||||||
|
[rec] = sink.calls
|
||||||
|
assert rec.args["size"] == rec.args_size and is_truncated(rec.args)
|
||||||
|
# Under a small cap the preview shrinks so the marker fits.
|
||||||
|
assert len(rec.args["preview"]) == preview_chars(500) == 125
|
||||||
|
assert json_size(rec.args) <= 500
|
||||||
|
|
||||||
|
async def test_none_means_unlimited(self):
|
||||||
|
app, sink, mw = make_app(max_payload_bytes=None)
|
||||||
|
await call(app, mw, "big", {"n": 20000})
|
||||||
|
[rec] = sink.calls
|
||||||
|
assert len(rec.result["rows"]) == 20000
|
||||||
|
|
||||||
|
async def test_meta_mode_stores_no_payload_but_sizes(self):
|
||||||
|
app, sink, mw = make_app(mode="meta")
|
||||||
|
await call(app, mw, "big", {"n": 2000})
|
||||||
|
[rec] = sink.calls
|
||||||
|
assert rec.args is None and rec.result is None
|
||||||
|
assert rec.result_size > 65536
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("before_end", [40, 15])
|
||||||
|
async def test_redaction_happens_before_the_cap(self, before_end):
|
||||||
|
# At 15 the preview ends inside the secret: cutting the raw JSON there
|
||||||
|
# would leave a fragment too short for the pattern to match.
|
||||||
|
app, sink, mw = make_app(max_payload_bytes=4000)
|
||||||
|
pad = preview_chars(4000) - len('{"blob": "') - before_end
|
||||||
|
blob = "p" * pad + " " + SECRET + " " + "q" * 5000
|
||||||
|
await call(app, mw, "echo", {"blob": blob})
|
||||||
|
[rec] = sink.calls
|
||||||
|
for stored in (rec.args, rec.result):
|
||||||
|
assert is_truncated(stored)
|
||||||
|
assert SECRET[:8] not in stored["preview"]
|
||||||
|
assert "[REDACT" in stored["preview"]
|
||||||
|
|
||||||
|
async def test_secret_key_redacted_in_preview(self):
|
||||||
|
app, sink, mw = make_app(max_payload_bytes=200)
|
||||||
|
app.tool(lambda api_key, pad: "ok", name="login")
|
||||||
|
await call(app, mw, "login", {"api_key": "hunter2-secret", "pad": "x" * 500})
|
||||||
|
[rec] = sink.calls
|
||||||
|
assert "hunter2" not in rec.args["preview"]
|
||||||
|
assert REDACTED in rec.args["preview"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [0, -1, True, "64k", 1.5])
|
||||||
|
def test_invalid_cap_rejected(self, bad):
|
||||||
|
with pytest.raises(ValueError, match="max_payload_bytes"):
|
||||||
|
make_app(max_payload_bytes=bad)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoSecondEncoding:
|
||||||
|
async def test_payloads_encoded_once(self, monkeypatch):
|
||||||
|
# The size columns and the cap share one measurement per payload,
|
||||||
|
# whether or not the payload is over the cap.
|
||||||
|
app, sink, mw = make_app(max_payload_bytes=1000)
|
||||||
|
measured = []
|
||||||
|
original = payload_mod.json_size
|
||||||
|
|
||||||
|
def counting(value):
|
||||||
|
measured.append(1)
|
||||||
|
return original(value)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"fastmcp_feedback.instrumentation.middleware.json_size", counting
|
||||||
|
)
|
||||||
|
await call(app, mw, "echo", {"blob": "small"})
|
||||||
|
await call(app, mw, "big", {"n": 5000})
|
||||||
|
assert len(measured) == 4 # args and result, two calls
|
||||||
|
assert is_truncated(sink.calls[1].result)
|
||||||
|
|
||||||
|
def test_preview_stops_early(self, monkeypatch):
|
||||||
|
chunks = []
|
||||||
|
encoder = payload_mod._ENCODER
|
||||||
|
|
||||||
|
class Counting:
|
||||||
|
def iterencode(self, value):
|
||||||
|
for chunk in encoder.iterencode(value):
|
||||||
|
chunks.append(chunk)
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
monkeypatch.setattr(payload_mod, "_ENCODER", Counting())
|
||||||
|
huge = {"rows": ["x" * 100] * 100_000} # about 10 MB
|
||||||
|
text = payload_mod.preview(huge)
|
||||||
|
assert len(text) == PREVIEW_CHARS
|
||||||
|
assert len(chunks) < 50
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventAttrs:
|
||||||
|
async def test_large_attr_replaced_small_ones_kept(self):
|
||||||
|
app, sink, mw = make_app(mode="meta")
|
||||||
|
await call(app, mw, "report", {"error": "E" * 100_000})
|
||||||
|
[ev] = sink.events
|
||||||
|
assert ev.attrs["ok"] is False
|
||||||
|
assert is_truncated(ev.attrs["error"])
|
||||||
|
assert ev.attrs["error"]["size"] == json_size("E" * 100_000)
|
||||||
|
assert json_size(ev.attrs) <= 65536
|
||||||
|
|
||||||
|
async def test_small_attrs_untouched(self):
|
||||||
|
app, sink, mw = make_app(mode="meta")
|
||||||
|
await call(app, mw, "report", {"error": "short"})
|
||||||
|
assert sink.events[0].attrs == {"ok": False, "error": "short"}
|
||||||
|
|
||||||
|
async def test_attrs_redacted_before_the_cap(self):
|
||||||
|
app, sink, mw = make_app(mode="meta", max_payload_bytes=4000)
|
||||||
|
error = "x" * (preview_chars(4000) - 30) + f" token {SECRET} " + "y" * 9000
|
||||||
|
await call(app, mw, "report", {"error": error})
|
||||||
|
stored = sink.events[0].attrs["error"]
|
||||||
|
assert SECRET[:8] not in stored["preview"] and REDACTED in stored["preview"]
|
||||||
|
|
||||||
|
async def test_outside_a_call_and_unlimited(self):
|
||||||
|
app, sink, mw = make_app(mode="meta", max_payload_bytes=None)
|
||||||
|
assert mw.record_event("job.log", key="j", attrs={"log": "L" * 200_000})
|
||||||
|
await mw.flush()
|
||||||
|
assert sink.events[0].attrs["log"] == "L" * 200_000
|
||||||
|
|
||||||
|
async def test_llm_call_attrs_capped_text_bounded(self):
|
||||||
|
app, sink, mw = make_app(mode="meta", capture_llm_text=True)
|
||||||
|
assert mw.record_llm_call(
|
||||||
|
model="m",
|
||||||
|
prompt="P" * 100_000,
|
||||||
|
attrs={"messages": ["M" * 1000] * 100},
|
||||||
|
)
|
||||||
|
await mw.flush()
|
||||||
|
[ev] = sink.events
|
||||||
|
assert len(ev.attrs["prompt"]) == mw.max_llm_text_chars
|
||||||
|
assert is_truncated(ev.attrs["messages"])
|
||||||
|
assert ev.attrs["model"] == "m" and ev.attrs["ok"] is True
|
||||||
|
|
||||||
|
def test_many_small_values_become_one_marker(self):
|
||||||
|
attrs = {f"k{i}": "v" * 50 for i in range(500)}
|
||||||
|
capped = cap_attrs(attrs, 1000)
|
||||||
|
assert is_truncated(capped) and capped["size"] == json_size(attrs)
|
||||||
|
|
||||||
|
def test_tiny_cap(self):
|
||||||
|
capped = cap_attrs({"a": "x" * 100}, 10)
|
||||||
|
assert is_truncated(capped) and len(capped["preview"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownstream:
|
||||||
|
async def test_marker_round_trips_through_database(self, db_url):
|
||||||
|
sink = DatabaseSink(db_url("cap"), create_tables=True)
|
||||||
|
try:
|
||||||
|
app, _, mw = make_app(mode="meta")
|
||||||
|
mw.dispatcher.sinks.append(sink)
|
||||||
|
await call(app, mw, "report", {"error": "E" * 100_000})
|
||||||
|
rows = await mw.events_for("j1")
|
||||||
|
[row] = [r for r in rows if r["kind"] == "job.finished"]
|
||||||
|
assert row["attrs"]["error"][TRUNCATED] is True
|
||||||
|
assert row["attrs"]["ok"] is False
|
||||||
|
finally:
|
||||||
|
await sink.engine.dispose()
|
||||||
|
|
||||||
|
def test_embedding_text_uses_the_preview(self):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
marker = payload_mod.marker("disk full: " + "z" * 5000, 5013, 100)
|
||||||
|
record = EventRecord(
|
||||||
|
kind="job.failed",
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
attrs={"error": marker},
|
||||||
|
)
|
||||||
|
[text] = extract_texts([record])
|
||||||
|
assert text.text.startswith('"disk full: zzz')
|
||||||
|
assert "_truncated" not in text.text
|
||||||
@@ -601,7 +601,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp-feedback"
|
name = "fastmcp-feedback"
|
||||||
version = "2026.10.2.1"
|
version = "2026.10.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
|
|||||||
Reference in New Issue
Block a user