Skip to content
mcpd
no daemon

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 })
})

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()
})

A feedback record takes these fields:

FieldValue
kindthumbs here. Also rating, correction, tag and comment.
valueup or down for thumbs. mcpd stores what you send, so check it first.
trace_idThe run the vote is about. Add span_id to judge one step.
commentOptional text from the person. It is stored, and swept by retention and erasure.
author_kindWho voted, such as end_user or reviewer. The feedback list filters on it.
author_idOptional. Hashed at ingest with the same key as user_id.
One run, six signals trace_id 7f3a91c2…
  • Spans 9
  • Logs 7
  • Metrics 3
  • Scores 2
  • Feedback 1
  • Aspect notes 1
All six are stored against the same trace and span ids. A spike on a chart opens the run that caused it, and a run shows the lines it logged.

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 seeWhy, 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.

mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.