Report a problem
One button inside your own app: the person says what went wrong, marks where if they like, and the minutes before are attached. The report reaches your Inbox as a task, or waits for an admin when nobody vouched for the reporter.
What a reporter sees
People using your app see a large Report a problem button in a corner of the page — or nothing at all, when you open it from a control of your own, such as a Help menu item. It speaks Czech or English: the browser's language unless your app sets one.
- Describe first. A small card asks What went wrong?. A sentence and Send is a whole report. The card says how many minutes before are attached and lists what is included.
- Show us where, if they like. Show us where freezes the page under a clear layer, and the reporter marks the problem with a circle, a box, an arrow, or a hide that blanks a private part of the screen. Marks are numbered 1, 2, 3…, each with an optional note. Until the first mark exists, a coach card stays beside the pointer and shows the gesture: click, hold and move around the problem. From the keyboard, Enter starts a mark, the arrows move it, Shift with the arrows resizes it, and Enter places it.
- Their own shortcut. There is none by default. In the card's settings (⚙) a reporter records one by pressing it — it needs Ctrl, Alt or ⌘, and Esc cancels — and from then on it opens the card anywhere in your app. It is kept in that browser.
- After Send the card thanks them, and offers See your report when your deployment runs a reporter portal. A failed send keeps every word; Try again resumes the same report instead of sending a second one.
A reporter your app identified can also tick Email me when it's fixed.
Installing it
The component is the report face of @vitrinka/web. Every vitrinka deployment serves it as one file, so a script tag is enough:
<script type="module" src="https://vitrinka.example.com/vendor/report-widget-1.mjs"></script>
<vitrinka-report key="vkb_…" locale="cs"></vitrinka-report>The deployment that receives the reports serves the file without caching (on the hosted service, https://app.vitrinka.ai/vendor/report-widget-1.mjs), so what the widget captures never drifts from what the server expects. base defaults to the origin the script came from. The file is an ES module: it defines the element as it loads, exports mountReport and identify for a page that would rather script it, and fetches rrweb's recorder from the same deployment only when the project records the page.
In an app with a build step, install the package and mount it. rrweb, a peer dependency, loads only when the project records the page:
bun add @vitrinka/web rrwebimport { mountReport } from "@vitrinka/web/report";
const report = mountReport({ key: "vkb_…", base: "https://vitrinka.example.com", locale: "cs" });
report.open();In React, mount it once and hand it the app's language and the reporter's token as they change. A language switch repaints the card without losing the draft or the marks:
import { useEffect, useRef } from "react";
import { mountReport, type ReportHandle } from "@vitrinka/web/report";
export function ReportAProblem({ locale, token }: { locale: string; token?: string }) {
const report = useRef<ReportHandle | null>(null);
useEffect(() => {
report.current = mountReport({ key: "vkb_…", base: "https://vitrinka.example.com" });
return () => report.current?.unmount();
}, []);
useEffect(() => report.current?.setLocale(locale), [locale]);
useEffect(() => report.current?.identify(token ? { token } : null), [token]);
return null;
}A page runs one capture. When the recorder pill of @vitrinka/web is mounted it owns the capture, and Report a problem then sends its reports without one — mount one face per page, picked by your app's mode.
Options
| Option | Attribute | What it does |
|---|---|---|
key | key | The project's vkb_ report key. Required. |
base | base | The vitrinka origin the reports go to; the script tag defaults to where it was served from. |
button | button | false hides the floating button, so the card opens only from your own control. |
openSelector | open-selector | A CSS selector of your own controls that open the card. |
locale | locale | cs or en. Unset, the browser's language, and English when it is neither. Change it live with setLocale() or the attribute. |
capture | — | Narrows the project's capture profile — below. |
identity | — | Who reports, the same as identify() — below. |
The handle and the element both offer open(), close(), identify() and setLocale(); the handle also has unmount().
The report key
Mint the key in Settings → Workspace → Tokens as a report key for one project, with the origins allowed to send from: an empty list admits no browser, * admits any. The key is publishable. It reads the project's public configuration and sends reports into that one project; it never reads a report back. Requests are rate limited per key, and what is sent passes recorder redaction.
Who reports
Your app tells the widget who is reporting in one of three ways, and that decides whether the report reaches the team at once.
| Your app passes | What happens |
|---|---|
| A token your server signed | The reporter is who the token says. The report goes straight to the team's Inbox, and the reporter follows it on the portal. |
| An email, unsigned | The report is bound to that address and waits for an admin. Nothing becomes readable to the address until its owner signs in to the portal. |
| Nothing | Anonymous: the report waits for an admin. |
report.identify({ token });
report.identify({ email, name });
report.identify(null);Identity lives in memory only, so pass it on every page load. The identify exported by @vitrinka/web/report names the reporter for every widget on the page; a widget's own identify() wins.
The host signing key
A project admin mints it in Settings → Projects, on the project's Report a problem tab, under Host signing key. It is shown once, starts with vhk_, and belongs in your app's server-side secrets — never in a browser. Minting again replaces it, and the old key stops working at once. revoke stops every host-signed sign-in; until a new key is minted, reports from your app wait for review. Replacing or revoking the key also signs out of the portal every reporter it signed in, since a key is changed because it may have leaked; reporters who signed in with an emailed code or link stay signed in. Minting and revoking are audited as report.host_key, with the number of portal sessions ended.
Signing a reporter's token
The token is a compact JWS signed with HS256 over these claims:
| Claim | Value |
|---|---|
sub | Your app's id for the user. |
email | Their address, one plain mailbox. |
name | Optional: how the team and the portal show them. |
iat, exp | Seconds since the epoch; exp at most 24 hours after iat. Mint one per page session. |
aud | <workspace>/<project> — the workspace and project the key belongs to, shown when you mint it. |
The whole key, vhk_ included, is the HMAC secret. A token that fails verification never loses the report: the report waits for review, and the reply to the widget's first call carries identityError, which says what was wrong — the audience, the expiry, a signature from another key.
In Node:
import { createHmac } from "node:crypto";
const b64 = (v: string | Buffer) => Buffer.from(v).toString("base64url");
export function reporterToken(user: { id: string; email: string; name?: string }): string {
const now = Math.floor(Date.now() / 1000);
const head = b64(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64(JSON.stringify({ sub: user.id, email: user.email, name: user.name, iat: now, exp: now + 3600, aud: "acme/shop" }));
const sig = b64(createHmac("sha256", process.env.VITRINKA_REPORT_HOST_KEY!).update(`${head}.${body}`).digest());
return `${head}.${body}.${sig}`;
}In Go:
package reporter
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"time"
)
// Token signs a reporter's identity with the project's host signing key.
func Token(key, sub, email, name string) (string, error) {
now := time.Now().Unix()
claims, err := json.Marshal(map[string]any{
"sub": sub, "email": email, "name": name,
"iat": now, "exp": now + 3600, "aud": "acme/shop",
})
if err != nil {
return "", err
}
enc := base64.RawURLEncoding
body := enc.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + "." + enc.EncodeToString(claims)
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(body))
return body + "." + enc.EncodeToString(mac.Sum(nil)), nil
}The same token signs the reporter in to the portal — signing in.
The capture profile
What the widget keeps before a report is the project's capture profile. A project admin sets it on the same Report a problem tab, and the widget reads it on load with the rest of its configuration from GET /api/v1/report-config.
| Setting | Default | What it decides |
|---|---|---|
| Storage | browser storage | browser also writes the masked capture to the browser's IndexedDB, so the minutes before a report survive the page loads in between; memory keeps it in the tab only. |
| Window | 300 seconds | How far back the capture reaches: 10 seconds to 30 minutes. |
| Size | 12 MiB | The replay's cap, 1 to 64 MiB. Past it the oldest part goes, and the replay is marked truncated. |
| Classes | interactions, console, network, errors | interactions — the masked replay of the page; errors — uncaught errors, console.error and failed requests; console — every console message; network — every request; full — all four. |
Your app can only narrow the profile — memory instead of browser storage, a shorter window, a lower cap, fewer classes — never widen it:
mountReport({ key: "vkb_…", capture: { storage: "memory", windowSec: 120 } });- Masking happens before storage. What the browser keeps is exactly what would be sent, masked under the project's redaction profile, set in the header of its Reports panel:
strict(the default) masks all text and every input value,balancedinput values,fullnothing. A hide mark blanks its region in every frame before anything leaves the browser. - Stored capture expires. A stored piece leaves the browser when it falls out of the window, when that tab's report is sent, and on the next page load past its age. Switching the project to memory deletes it.
- What the reporter types stays in memory — the description and the notes are never stored.
- Requests are metadata only — method, path, status and duration, never a header or a body.
What happens next
- Filed at once. A report your app vouched for, or one written on the portal, becomes a task straight away: a bug waiting in the project, at the top of the team's Inbox. In the background its capture becomes a session with a replay, a card lands on the project's bug board, and each mark except a hide becomes an annotation on the screen the reporter marked, linked to the task. With Eve on, it arrives sorted — Eve's triage.
- Held for review. An anonymous report, or one whose email nobody vouched for, is quarantined. Its capture is staged and reachable only by workspace admins, so nothing it carries reaches a board, search or an AI before a person has looked at it.
Quarantine and review
Workspace admins review held reports under Bug reports in the QA module.
| Action | Who | What happens |
|---|---|---|
| Inspect | workspace admin | What was written, the marks, the console, the requests and the replay. |
| Redact | workspace admin | Drop entries or the replay before release. |
| Approve | workspace admin | The capture becomes a session, the report lands on the project's bug board, and it reaches the Inbox as a task — the same path a vouched report takes. |
| Reject | workspace admin | The report is closed and nothing is released. |
With the project's auto-ticket switch on, approval also opens a ticket through the project's connector.