@daybook/feedback
v1.3.1
Published
In-app feedback for the web: point at what is wrong, and everything a triager needs is captured automatically.
Downloads
1,257
Maintainers
Readme
@daybook/feedback
In-app feedback for the web. The reporter points at what is wrong and says what happened; everything a triager needs is captured without asking them.
9 KB gzipped, no dependencies.
Install
Script tag — no build step
<script src="https://daybook.team/sdk/v1/feedback.js"></script>
<script>
var pm = DaybookFeedback.init({
ingestKey: 'pmi_…',
reporter: { externalId: user.id, email: user.email },
})
</script>The /sdk/v1/ path is versioned on purpose. A script tag points at a URL forever, so what is there
stays compatible with what /ingest/v1 accepts; a breaking change would appear at /sdk/v2/ beside
it rather than replacing it under everybody's feet.
JavaScript, as a module
npm install @daybook/feedbackimport { init } from '@daybook/feedback'
const pm = init({
ingestKey: 'pmi_…', // per-environment, write-only, safe to ship
app: { version: '1.4.0' },
reporter: { externalId: user.id, email: user.email },
})React
Mount it once, near the root — not per route, or each navigation starts a second widget.
// FeedbackWidget.tsx
import { useEffect } from 'react'
import { init } from '@daybook/feedback'
export function FeedbackWidget({ user }: { user: { id: string; email: string } | null }) {
useEffect(() => {
const pm = init({
ingestKey: import.meta.env.VITE_FEEDBACK_KEY,
surface: 'web-app',
reporter: user ? { externalId: user.id, email: user.email } : undefined,
})
// Strict mode runs effects twice in development. stop() is what makes that harmless —
// without it you get two launchers, and the first one holds the events.
return () => pm.stop()
}, [user?.id])
return null
}Render <FeedbackWidget user={user} /> once in your layout. It draws nothing itself: the widget
attaches to document.body, so it is not affected by a parent's overflow or stacking context.
Vue
// useFeedback.ts
import { onMounted, onUnmounted, watch, type Ref } from 'vue'
import { init, type DaybookFeedback } from '@daybook/feedback'
export function useFeedback(user: Ref<{ id: string; email: string } | null>) {
let pm: DaybookFeedback | null = null
// Mounted rather than created in setup(), so it does nothing during SSR — the widget needs a
// document. Whoever is signed in *now* is passed to init: a watcher with `immediate: true`
// would fire during setup, before this runs, and identify() on nothing is silently no-op.
onMounted(() => {
pm = init({
ingestKey: import.meta.env.VITE_FEEDBACK_KEY,
surface: 'web-app',
reporter: user.value
? { externalId: user.value.id, email: user.value.email }
: undefined,
})
})
// identify() re-handshakes, so signing in and out is reflected without remounting — which
// matters because an approval granted to that person is only seen at the next handshake.
watch(user, next => pm?.identify(
next ? { externalId: next.id, email: next.email } : null))
onUnmounted(() => pm?.stop())
}Call useFeedback(user) once, from App.vue.
A launcher appears bottom-right. F opens the picker, Esc backs out, Cmd/Ctrl+Enter sends.
The key is the only thing you have to supply. Reports go to Daybook's own API by default — where
they are received is ours to keep working, not a hostname for you to paste and then keep in step
with us. Pass apiUrl only to point at something that is not Daybook: a self-hosted install, or a
local API while you are developing.
Allow your domain first
The widget only runs on domains you have listed. In Daybook, open Project settings → Feedback widget, and add your origin to an environment. That is the whole step — it works the moment you save it. There is nothing to verify, because the list only ever narrows what a key already permits: it cannot grant anyone access to anything.
localhost is an origin like any other. Put it on the development environment and you are
working in a minute.
Or let your assistant do all of it. If you have Daybook connected as an MCP server in the
project you are adding the widget to, get_feedback_install returns the package, the snippet for
your framework, the components you can file against, and — most usefully — a blockers list saying
what is still unconfigured. allow_feedback_domain and create_feedback_key do the two steps that
need doing. That path exists because the SDK fails quietly by design: without it an assistant sets
everything up, sees no error, and has no way to tell a working install from a silent one.
On a domain that is not listed, the handshake returns 403 origin_not_allowed and the widget quietly
does not appear — which is also what it does on a revoked key and on a dead network. A feedback
button must never break the page it is on.
One key, many surfaces
One key serves a whole product. Each install says which part of it it is:
init({ ingestKey: 'pmi_…', surface: 'mobile-web' })surface is a component key from your project. The key still fixes the project and the environment
— the parts that must not be forgeable — so this only routes within a project you already write
to. An unknown value falls back to the key's own component rather than failing: losing the
attribution is not a reason to lose the report.
Who is reporting
init takes a reporter, and identify() updates it once the host app knows who signed in:
const pm = init({ … })
pm.identify({ externalId: user.id, email: user.email, name: user.name })
pm.identify(null) // signing outThat is enough for most installs. But a browser saying who it is, is a claim — anybody viewing the page can type any address into the console. If your integration is set to require it, your backend signs the identity:
// On your server. The identity secret never reaches a browser.
const signature = crypto
.createHmac('sha256', process.env.DAYBOOK_IDENTITY_SECRET)
.update(user.id)
.digest('hex')// In the page, with the signature your server produced.
init({ …, reporter: { externalId: user.id, email: user.email }, signature })pm.authenticate(signature) sets it later, for an app that fetches it after boot.
Same shape as Intercom's and Crisp's identity verification, deliberately — if you have done one of those, you have done this.
Or sign in with Daybook
A third mode, for when the reporter already has a Daybook account — an internal tool, or your own team dogfooding. Then there is no secret to configure at all:
const pm = init({ ingestKey: 'pmi_…' })
// It opens a popup, so a real click has to trigger it — a call at startup is blocked.
document.querySelector('#feedback-signin')
?.addEventListener('click', () => pm.signIn())In React, hold the instance in a useRef and call it from your own button's onClick; in Vue,
return () => pm?.signIn() from the composable. In this mode the reporter is settled by the
sign-in, so nothing needs reporter or identify().
They approve a short consent screen, and we authenticate them rather than taking your word for it.
Not for a product whose reporters are your customers. They would have to create an account with us to report a bug, which is how feedback stops arriving.
Approval
Separately from how an identity is established, the integration decides who may file: Open (anyone, anonymously), Identified (anyone who says who they are), or Approved (only people somebody let in). Under Approved, a new reporter sees "Feedback access pending approval" instead of the launcher until a maintainer approves them, and a blocked one sees nothing at all.
onReady tells the host app which of those happened, if you would rather say it in your own words:
init({ …, onReady: h => console.log(h.reporterStatus) }) // Approved | Pending | Blocked | AnonymousWhat it captures
The reason this exists rather than a form: the questions that follow "it looks wrong" — which browser, which build, what errored just before — are all answerable at the moment of capture, and unanswerable three days later when the reporter has moved on.
The element, as a CSS selector plus the click position as a fraction of that element's bounding box. Not page pixels: an anchor recorded on a desktop layout has to still mean something on a phone. An 80-character text snapshot comes too, so a human can find the thing after a redesign breaks the selector.
Page and device — route, title, referrer, locale, timezone, viewport with DPR, and a readable browser and OS rather than a raw user-agent string.
Breadcrumbs — the last 40 navigations, clicks, console errors and failed or slow requests.
The page itself, as
page-snapshot.json: the markup with its classes and attributes, the computed style of the pinned element and its ancestors, everything floating over the page at that moment, what had focus, where the page was scrolled to, and which stylesheets were readable.This is the half of a report a screenshot cannot give you, and it needs no permission — it is the page's own state, read by script that is already running on it. A picture is for a person; most feedback now passes an assistant first, and to an assistant a PNG is nearly opaque where a DOM is the whole answer.
It obeys
redactexactly as everything else does: field values are described rather than reproduced, anddata-daybook-privatesubtrees are replaced by a marker. Script bodies are dropped, attributes and text are capped, and the whole thing truncates rather than growing.What it does carry is the page's visible text, because that is what the page is. On screens showing personal data, decide whether that belongs in a report —
dom: falseturns it off, anddata-daybook-privatewithholds a subtree without turning the whole thing off.A screenshot, if the reporter ticks "Capture the screen" and consents to the browser's share prompt. The widget hides itself for the frame, so the picture is of the page underneath rather than of the form that was just filled in — and if the reporter pinned an element, that element is ringed in the picture.
The ring is the difference between a report you can act on and one you have to reconstruct. A selector and a text snapshot tell you which node; they do not tell you which of the forty things on that screen the sentence is about. It is drawn in the widget's own layer, never on your page: no style of yours is touched, nothing reflows, and there is no undo to get wrong. The rest of the page is left exactly as it was — no dimming — because the screenshot is also the record of what the page looked like, and a report about a colour should not arrive with the colours changed.
screenshots decides what the composer offers, and takes three values:
| Value | The composer |
|---|---|
| true (default) | offers the capture, ticked |
| 'optional' | offers the capture, not ticked |
| false | does not offer it at all |
There is no setting that shows the control and then ignores it. A tick box that does nothing is worse than no tick box.
Where it sits
placement puts the widget in a corner: bottom-right (the default), bottom-left, top-right
or top-left. Set here it overrides the corner chosen in the workspace's own settings, which is
the right way round — the workspace owner is choosing a default for every install, and whoever
writes this line is looking at the page.
init({ ingestKey: '...', placement: 'bottom-left' })It moves out of your other buttons' way
Most pages already have something in the bottom-right corner: a chat bubble, a back-to-top arrow, a cookie notice. The widget looks at what is painted in the corner it is about to occupy and stacks above it rather than on it — including things that are not there yet, like a back-to-top button that appears once the reporter scrolls, which is re-checked on scroll and whenever the page changes.
There is no list of selectors involved and there will not be one: what a chat bubble and a back-to-top button have in common is not a class name, it is being fixed, painted and small in the corner. Anything larger than a quarter of the viewport is treated as scenery — a modal backdrop is not something to politely stack above — and if getting clear would mean moving more than a third of the way up the page, it stays put and overlaps instead.
Turn it off with avoidOverlap: false if your page positions things by measuring ours, or if the
movement bothers you more than the overlap would.
Moving the panel
The composer is dragged by its header, and lands against whichever side of the window it is let go nearest — only that axis pins, so dragging it to the left edge halfway down leaves it halfway down. A double-click on the header puts it back.
A drag lasts for one report. Sending resets it, so does closing, and the next report opens where
placement says. It used to be remembered across visits, which sounds helpful and is not: the
widget then turns up somewhere different on every page, and the somewhere is wherever it was last
shoved aside — a position chosen to be temporary.
The confirmation always appears at home, never where the panel was dragged to. The reporter moved the panel to see what was under it; putting the receipt back over that same spot answers a question nobody asked and covers the thing they moved it for.
It closes on the cross or on Cancel, and on nothing else — not Escape, not a click on the page behind it. Every one of those gestures gets pressed while a report is being written, and each of them used to throw the words away.
Redaction
On by default, and it runs before anything leaves the device — the server cannot undo a leak that has already crossed the wire.
- Input values are described, never reproduced:
[7 chars], or[redacted]when the field name suggests a secret. - Query strings are stripped from every captured URL.
- Emails, JWTs, bearer tokens and long digit runs are masked in console text.
- Password fields and anything you mark
data-daybook-privateare blurred before a screenshot is taken.
<div data-daybook-private>Card ending 4242</div>Turn it off with redact: false only if you are certain about what is on screen.
Offline
A report written the moment something breaks is frequently written the moment the network is also
broken. Failed sends are queued in localStorage and retried on the next visit, carrying an
idempotency key so a retry the server already received returns the original rather than filing a
duplicate. 4xx responses other than 429 are not retried — a client that retries a rejected body
forever becomes a denial of service against its own backend.
Without the UI
const pm = init({ … })
pm.open() // your own "Report a problem" menu item
await pm.report('Checkout threw', { // a caught error, no user present
kind: 'bug',
context: { orderId: '123' },
})report() takes no screenshot: there is nobody there to consent to a screen share.
The look is set in Daybook, not in code
Everything about the widget's appearance lives on the integration, so it is changed on the Feedback widget page in Daybook and takes effect on every install of that key without a deploy:
| | |
|---|---|
| Button style | pill, pill-icon, icon, text, outline |
| Button text | Anything up to 24 characters. Defaults to "Feedback" |
| Colour scheme | auto (the default), light, dark |
| Corner | Bottom right by default — and placement in init() overrides it |
| Accent | Any CSS colour |
Auto is the default and is the one to keep. The widget is a guest on your page, and your page is already light or dark for that reader; following their own setting is almost always right. Pin it only when your app is one or the other regardless of the system.
The five button styles exist because one button cannot be right for every page. A product with its own bold chat bubble does not want a second bold pill beside it; a spare documentation site does not want a filled button at all; a dense application usually wants the smallest possible mark. All five keep the same hit area in the same corner, so nothing else about the widget changes with the choice.
If you are building your own settings screen, previewLauncher(element, theme) draws the real
button — the real stylesheet, in a shadow root — so a preview cannot drift from the thing it
previews.
Theming, but not styling
The widget renders in a shadow root. A host page's * { } reset, its button { all: unset }, or its
own stacking context cannot reach in — which matters because the page someone is reporting a problem
on is exactly the page whose CSS you should not trust.
So it is themed from Daybook rather than from your stylesheet: accent colour, corner radius, position and launcher label are set on the integration and arrive with the handshake, which means changing them needs no redeploy of your site. Anything not on that list is not settable, on purpose.
Limits
Two, and they are different things. A burst limit of 60 reports a minute per install stops a leaked key becoming a write loop. A period quota belongs to the whole Daybook project and is what runs out at the end of a month; the integrations screen shows how much of it is spent.
Attachments go through the API rather than straight to storage, capped per environment — 8 MB by default, which is generous for a screenshot.
