Quality
Collect documentation feedback and turn it into fixes
A page-level helpfulness report in PostHog, the reader comments behind your weakest pages, and a pull request that records why each fix was made.
By Hayden Bleasel9 min read

Every Blume docs page ends with a "Was this page helpful?" rating, and it's on by default. Blume doesn't store the answers. Each rating goes out as a feedback event through your analytics adapters, so to find the pages that confuse readers you need three things: an adapter that records event properties, a comment box that asks readers what went wrong, and a query that ranks pages by how often readers said no.
This guide sets that up with PostHog. You turn on written feedback, confirm the events arrive, build a page-level helpfulness report in SQL, read the comments behind the worst pages, and ship a fix with a record of why. The same events reach Mixpanel, Segment, Google Analytics, and most other adapters, with the limits covered below.
Without an analytics adapter, the widget still shows and thanks the reader, but the answer goes nowhere. Add an adapter or set feedback: false. And if you need to reply to the person who wrote in, this is the wrong tool: the widget is anonymous and has no reply path, so send those readers to your support channel instead.
What the widget sends
A reader clicks Yes or No, and with comments on, a "Tell us more (optional)" box appears under the thank-you. That produces up to two events:
| Event | Sent when | Properties |
|---|---|---|
feedback | The reader rates the page | helpful ("yes" or "no"), path, title |
feedback_comment | The reader sends a comment after rating | The same three, plus comment, cut at 1,000 characters |
path is the URL path the reader was on, like /docs/webhooks/verify-signatures, and title is the browser tab title, like "Verify signatures - Acme Docs". An empty comment sends nothing. Both events also fire as a blume:track event on window, which is how you'll check them locally.
Choose a provider that keeps the comment
Every adapter with an event API gets these events, but not every provider keeps what you need. A rating is useless without its helpful value, and a comment is only as good as the part that survives:
| Adapter | What you get |
|---|---|
posthog(), segment() | Ratings and whole comments |
mixpanel() | Ratings, and comments up to Mixpanel's 255-byte limit on string values |
vercel() | Nothing on Hobby, which has no custom events. Pro keeps two properties per event, fewer than a rating sends; Web Analytics Plus keeps eight. Values over 255 characters aren't allowed |
googleAnalytics() | Ratings, and comments cut at 100 characters |
plausible() | Ratings; comments are grouped by identical value, so it suits ratings better |
fathom(), clarity(), hotjar() | The event name only: a count of ratings, with no yes or no |
cloudflare(), clearbit() | Nothing: they have no event API |
Heap, Amplitude, Hightouch, LogRocket, Adobe, Google Tag Manager, Databuddy, Pirsch, and OneDollarStats get the full properties too, so check that provider's own limit on property length. This guide uses PostHog because it keeps comments whole and lets you query events with SQL.
Turn on comments and PostHog
Add the adapter with your PostHog project API key, and switch feedback from its default to an object with comments on:
import { defineConfig } from "blume";
import { posthog } from "blume/analytics";
export default defineConfig({
title: "Acme Docs",
analytics: [
posthog({
key: "phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
host: "https://us.i.posthog.com",
}),
],
feedback: { comments: true },
});The project key is the public, browser-side one from PostHog's install snippet, so it's safe to commit. For PostHog EU Cloud, set host to https://eu.i.posthog.com. Any other option you pass goes to posthog.init as it is, as long as it's a JSON value: functions fail config validation.
Consent and personal details
If you need to ask readers before analytics runs, add a consent adapter:
import { defineConfig } from "blume";
import { posthog } from "blume/analytics";
import { native } from "blume/consent";
export default defineConfig({
title: "Acme Docs",
analytics: [
posthog({
key: "phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
host: "https://us.i.posthog.com",
}),
],
consent: native({ policy: "/privacy" }),
feedback: { comments: true },
});With consent on, the rating stays hidden until the reader allows analytics, since from anyone else the answer would go nowhere. Your report then covers only readers who said yes, which is a smaller and possibly different group. Keep that in mind before you compare numbers with a period when consent was off.
The comment box takes free text, so readers will sometimes type an email address, an account ID, or an API key. Treat comments as personal data: limit who can open the PostHog project, mention the feedback box in your privacy policy, and never paste raw comments into a public issue or pull request.
Check that events arrive
Analytics loads in production builds only, so blume dev never sends anything to PostHog. The blume:track event still fires there, so you can check the widget before you deploy. Run npx blume dev, open any docs page, and paste this into the browser console:
addEventListener("blume:track", (event) => console.log(event.detail));Click No at the foot of the page (with consent on, accept the banner first), type a comment, and send it. The console logs both events:
{ event: "feedback", props: { helpful: "no", path: "/docs/webhooks/verify-signatures", title: "Verify signatures - Acme Docs" } }
{ event: "feedback_comment", props: { comment: "Test comment", helpful: "no", path: "/docs/webhooks/verify-signatures", title: "Verify signatures - Acme Docs" } }After you deploy, rate a page on the live site, then open PostHog's SQL editor and look for your events:
SELECT timestamp, event, properties.path, properties.helpful, properties.comment
FROM events
WHERE event IN ('feedback', 'feedback_comment')
AND timestamp > now() - INTERVAL 1 DAY
ORDER BY timestamp DESC
LIMIT 20Custom properties like path have no $ prefix in PostHog; the $ ones are PostHog's own.
Build a page-level report
Once ratings have built up, this query ranks pages by how many readers said no, with the rate and comment count beside it:
SELECT
properties.path AS page,
countIf(event = 'feedback') AS ratings,
countIf(event = 'feedback' AND properties.helpful = 'no') AS not_helpful,
round(100 * countIf(event = 'feedback' AND properties.helpful = 'no') / countIf(event = 'feedback')) AS not_helpful_pct,
countIf(event = 'feedback_comment') AS comments
FROM events
WHERE event IN ('feedback', 'feedback_comment')
AND timestamp > now() - INTERVAL 30 DAY
GROUP BY page
HAVING ratings >= 10
ORDER BY not_helpful DESC, not_helpful_pct DESC
LIMIT 25Here's the kind of output it gives, from made-up events for the Acme docs:
| page | ratings | not_helpful | not_helpful_pct | comments |
|---|---|---|---|---|
/docs/messages/send-sms | 50 | 13 | 26 | 7 |
/docs/webhooks/verify-signatures | 31 | 11 | 35 | 9 |
/docs/quickstart | 152 | 11 | 7 | 6 |
/docs/authentication | 76 | 9 | 12 | 2 |
Read it both ways. The count favors busy pages: the quickstart has as many "no" ratings as the webhook page, but out of five times the ratings, so 7% is close to background noise. The rate favors quiet pages, which is why HAVING ratings >= 10 drops any page with too few ratings to mean anything. Lower that floor on a small site, and raise it on a busy one. Here, the webhook page is where to start: over a third of its ratings are no, and it has the most comments to read.
Save the query as an insight so the team can reopen it. If you'd rather pick the window from the insight's date range, replace the timestamp condition with AND {filters}, and the same insight gives you before and after numbers later.
Read the comments behind a page
Pull every comment for the page you picked:
SELECT timestamp, properties.helpful AS helpful, properties.comment AS comment
FROM events
WHERE event = 'feedback_comment'
AND properties.path = '/docs/webhooks/verify-signatures'
AND timestamp > now() - INTERVAL 30 DAY
ORDER BY timestamp DESCThen sort each one by what it points to. Here are six of the page's nine comments. They're synthetic, but typical of what the box collects:
| Rating | Comment | Points to |
|---|---|---|
| no | Where do I get the signing secret? The page never says. | A missing step |
| no | signing secret?? | The same missing step |
| no | The Node example throws "Input buffers must have the same byte length" | A broken example |
| no | I wanted SMS delivery receipts, not webhooks | The wrong page |
| no | Webhooks sometimes arrive twice | The product, not the page |
| yes | Would love a Python version | A request |
Each kind leads somewhere different:
- Wrong or broken. Reproduce it first. Node's
crypto.timingSafeEqualthrows exactly that error when its two buffers differ in length, so a signature of the wrong length crashes the example instead of failing the check. Fix the example. - Missing. When more than one comment asks the same question, the page needs the answer. Check the product for where the secret really lives before you write it down.
- Wrong page. The reader landed on a page that isn't what its title or search result promised. Look at the title, the sidebar label, and what readers search for (the search analytics guide covers that), and link to the page they wanted.
- Product problems. Duplicate deliveries are a bug report. Send it to the product team; rewriting the page won't fix it.
- Requests and noise. Log requests where you plan docs work, and skip comments with nothing to act on.
Ship the fix and record why
Make the change in a pull request whose description carries the evidence, so a reviewer can check the reasoning and a later reader can find out why the page changed. Paraphrase the comments and count them; don't quote them:
Fix signature verification page after reader feedback
Feedback on /docs/webhooks/verify-signatures, last 30 days:
31 ratings, 11 not helpful (35%), 9 comments.
Themes (paraphrased):
- 2 comments: readers can't find the signing secret.
- 1 comment: the Node example throws when signature lengths differ.
Reproduced locally.
Not changed here:
- Duplicate deliveries: reported to the platform team.
- Python example: added to the docs backlog.
Changes:
- New "Find your signing secret" section, checked against the dashboard.
- The Node example compares lengths before timingSafeEqual.
Follow-up: rerun the feedback report for this page once it has
about as many new ratings as this window.If you set lastModified: "git" in your config, the page's "Last updated on" line moves once the fix deploys, so readers can see it changed too. See Last modified for the shallow-clone setting your CI needs.
Once the page has collected a similar number of new ratings, run the report again for the window since the deploy. A lower rate with fewer comments on the same theme is a good sign. Small samples swing, though, so treat one window as a signal, and keep reading the comments that still come in.
Troubleshooting
Nothing shows up in PostHog
Check that you're looking at a production build: blume dev never loads analytics. On the live site, run the console listener from Check that events arrive. If blume:track fires but PostHog gets nothing, a browser extension is likely blocking PostHog's script. PostHog's reverse proxy docs cover sending events through your own domain; point host at the proxy.
The comment box never appears
The box only shows after the reader rates the page, and only with feedback: { comments: true }. Setting enabled: false in the same object turns off the rating and the box together.
Comments arrive cut short
Blume sends up to 1,000 characters. Anything shorter than that in your dashboard is the provider's limit: 100 characters in Google Analytics, 255 bytes in Mixpanel (fewer characters for non-Latin text), and 255 characters in Vercel. Move comments to PostHog or Segment, or bridge the blume:track event to your own store with a script() adapter.
Ratings show up without yes or no
Fathom, Clarity, and Hotjar take a bare event name, so they count ratings without the helpful value. Add an adapter from the table above alongside them.
Some readers never see the rating
With a consent adapter set, the rating stays hidden until the reader allows analytics, and a reader who declines never sees it. That's expected, and it means your numbers only describe readers who opted in.
One page shows up under several paths
path is the URL the reader was on, so each locale and each frozen version of a page reports separately, like /fr/docs/quickstart next to /docs/quickstart. That's usually what you want, since a translation can be wrong where the source is right. To see them together, filter on the part of the path they share.
Next step
Find what readers search for and miss
Search sends its queries through the same adapter. Filter on searches with no results to find the pages your docs don't have yet.
Read the search analytics guideA step here not working for you? Report a broken step.