Add identity and enrichment
Two optional hooks add your own data to each call record: an identity resolver says who made the call, and an enricher adds fields of your choosing. Both may be plain functions or coroutines.
Identity resolver
Section titled “Identity resolver”identity_resolver(context) gets the FastMCP MiddlewareContext and returns a
dict. Three keys become columns of their own: user_sub, caller_kind and
client_id. Anything else goes into the identity JSON column.
With FastMCP’s built-in auth, read the access token:
from fastmcp import FastMCPfrom fastmcp.server.dependencies import get_access_tokenfrom fastmcp_feedback.instrumentation import MemorySink, instrument
app = FastMCP("My Server")
def who(context): """Who made this call. Return {} for anonymous calls.""" token = get_access_token() if token is None: return {} return { "user_sub": token.claims.get("sub") or token.client_id, "client_id": token.client_id, "caller_kind": "oauth", "scopes": token.scopes, # stored in the identity column }
sink = MemorySink()mw = instrument(app, [sink], identity_resolver=who)user_sub is what feedback linking uses to match
calls from clients that keep no session, so set it on any HTTP server that
collects feedback.
The resolver runs once per call, before the tool. Events the tool records
with record_event or record_llm_call get the same user_sub, caller_kind
and client_id as the call’s row, and the call’s record reuses the result
rather than asking twice:
@app.toolasync def render(scene: str) -> dict: mw.record_event("render.queued", key=scene) # carries the caller's user_sub return {"queued": scene}A value passed to record_event explicitly wins. An event recorded outside a
call, including from a task the tool started that records after the tool
returned, gets only what you pass.
The resolver is also called outside a tool call, when capture_recent_calls
needs the current user. There it receives an object whose only useful
attribute is fastmcp_context, so read identity from that or from FastMCP’s
dependency helpers, as above, rather than from context.message.
Enricher
Section titled “Enricher”enricher(tool, args, result, context) runs after the tool and returns extra
fields for the extra JSON column. result is the payload the record stores:
the structured content when the tool returned some, otherwise its content
items, and None when the tool raised.
Pass it next to the resolver, in place of the earlier instrument call:
async def enrich(tool, args, result, context): fields = {"server_version": "1.4.0"} # fills the server_version column if isinstance(result, dict) and "job_id" in result: fields["job_id"] = result["job_id"] # lets you join events by job id return fields
mw = instrument(app, [sink], identity_resolver=who, enricher=enrich)A server_version key goes to its own indexed column instead of extra. To
set one version for every record, pass server_version="1.4.0" to instrument
instead.
Writing a key such as job_id or speech_id into extra is how you join a
call to the events it started.
Limits
Section titled “Limits”- Hook output is redacted like arguments.
- An async hook that takes longer than
hook_timeout(0.25 seconds by default) is skipped for that call. A hook that raises is logged and skipped. Neither changes what the client receives, but the resolver runs before the tool, so a slow async resolver delays the tool by up tohook_timeout. Keep it cheap: read state you already have rather than calling out. - Neither hook runs for excluded tools or in mode
off. The enricher does not run for calls that are sampled out; the resolver does, since it runs before anyone knows whether the call will fail or record an event.