How to collect thumbs-up and thumbs-down feedback on LLM answers
Put a thumbs-up and a thumbs-down button under each answer. Each vote is stored on the trace that produced the answer, so you can open the exact run a person disliked.
Last updated
When you finish, every vote in your product is a feedback record in mcpd, attached to the run it judges, and you have a query that lists the runs people voted down. You need runs in mcpd from your chat or answer endpoint, sent with an mcpd SDK, and a backend that serves your front end.
1. Return the trace id with each answer
A vote has to name the run it is about. Read the trace id from the root span while it is open and send it to the browser with the answer.
the answer endpoint
// Your chat endpoint: return the trace id with the answer.
app.post('/api/chat', async (req, res) => {
let traceId = ''
const answer = await mcpd.withSpan('chat_turn', async (run) => {
traceId = run.traceId
return await answerQuestion(req.body.message)
})
res.json({ answer, traceId })
}) # Your chat endpoint: return the trace id with the answer.
@app.post("/api/chat")
def chat(body: ChatRequest):
with mcpd.trace("chat_turn") as run:
answer = answer_question(body.message)
return {"answer": answer, "trace_id": run.trace_id} 2. Add the buttons
The buttons post the vote to your own backend, never to mcpd. A key in a
browser is a public key, and mcpd answers no cross-origin browser request
unless it runs with --cors-origin.
the buttons
<div class="feedback" data-trace-id="TRACE_ID_FROM_THE_ANSWER">
<button type="button" data-vote="up">Helpful</button>
<button type="button" data-vote="down">Not helpful</button>
</div>
<script>
document.addEventListener('click', async (e) => {
const button = e.target.closest('[data-vote]')
if (!button) return
const box = button.closest('.feedback')
await fetch('/api/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ traceId: box.dataset.traceId, vote: button.dataset.vote }),
})
box.textContent = 'Thanks for the feedback.'
})
</script> 3. Record the vote from your backend
Your backend checks the vote and records it with the SDK. The SDK sends it in its next batch, through the same door as your spans, with the ingest key it already has.
record the vote
app.post('/api/feedback', (req, res) => {
const { traceId, vote, comment } = req.body
if (vote !== 'up' && vote !== 'down') return res.status(400).end()
mcpd.feedback({
kind: 'thumbs',
value: vote,
traceId,
comment,
authorKind: 'end_user',
authorId: req.user.id, // hashed by mcpd at ingest, like a user id
})
res.status(204).end()
}) from mcpd import FeedbackKind
@app.post("/api/feedback")
def feedback(body: FeedbackRequest, user: User):
if body.vote not in ("up", "down"):
raise HTTPException(400)
mcpd.feedback(
FeedbackKind.THUMBS,
body.vote,
trace_id=body.trace_id,
comment=body.comment,
author_kind="end_user",
author_id=user.id, # hashed by mcpd at ingest, like a user id
) # Needs a write key when the daemon has keys set.
curl -s -X POST http://localhost:4317/v1/api/feedback \
-H 'content-type: application/json' \
-H "authorization: Bearer $MCPD_WRITE_KEY" \
-d "{
\"feedback_id\": \"\",
\"trace_id\": \"$TRACE\",
\"kind\": \"thumbs\",
\"value\": \"down\",
\"comment\": \"Named the wrong depot.\",
\"author_kind\": \"end_user\",
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"correlation\": { \"project\": \"support\" }
}" A feedback record takes these fields:
| Field | Value |
|---|---|
kind | thumbs here. Also rating, correction, tag and comment. |
value | up or down for thumbs. mcpd stores what you send, so check it first. |
trace_id | The run the vote is about. Add span_id to judge one step. |
comment | Optional text from the person. It is stored, and swept by retention and erasure. |
author_kind | Who voted, such as end_user or reviewer. The feedback list filters on it. |
author_id | Optional. Hashed at ingest with the same key as user_id. |
- Spans 9
- Logs 7
- Metrics 3
- Scores 2
- Feedback 1
- Aspect notes 1
Feedback is one of the signals that share the run's trace id, so the vote shows in the run next to its spans, logs and scores. Machine scores are a separate signal, and mcpd never averages the two together; see Scores and feedback.
4. Find the runs people voted down
The feedback list filters by kind and time window, newest first. Filter the
value with jq, then open each run by its trace id. The trace list
takes has_feedback=true for every run with a vote of any kind.
the runs people disliked
# The thumbs-down votes of the last week, newest first, with the run each one judged.
curl -s --get http://localhost:4317/v1/api/feedback \
--data-urlencode 'project=support' \
--data-urlencode 'kind=thumbs' \
--data-urlencode 'from=-7d' \
| jq -r '.feedback[] | select(.value == "down") | [.timestamp, .trace_id, .comment] | @tsv'
# Or every run that has any feedback at all.
curl -s "http://localhost:4317/v1/api/traces?project=support&has_feedback=true" \
| jq -r '.traces[] | [.trace_id, .name, .status] | @tsv' A run that was voted down is a good test case. The regression test guide turns runs like these into a dataset that runs on every pull request.
Troubleshooting
| What you see | Why, and what to do |
|---|---|
422 with missing field `feedback_id` from POST /v1/api/feedback |
The endpoint takes a whole feedback record. Send feedback_id (an empty string lets
mcpd make one), timestamp and correlation, as in the cURL example.
|
401 or 403 from POST /v1/api/feedback |
The endpoint needs a key with the write scope. With no --write-key set, the daemon
accepts its ingest keys there.
|
The Python SDK logs feedback was dropped | The call named no trace id and no span was active. Pass the trace id you returned with the answer. |
Related guides
- How to group LLM traces into conversations
- How to run LLM regression tests in GitHub Actions
- How to read agent traces in Claude Code and Cursor
mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.