vitrinka.docs

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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 rrweb
import { 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

OptionAttributeWhat it does
keykeyThe project's vkb_ report key. Required.
basebaseThe vitrinka origin the reports go to; the script tag defaults to where it was served from.
buttonbuttonfalse hides the floating button, so the card opens only from your own control.
openSelectoropen-selectorA CSS selector of your own controls that open the card.
localelocalecs 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 passesWhat happens
A token your server signedThe 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, unsignedThe 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.
NothingAnonymous: 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:

ClaimValue
subYour app's id for the user.
emailTheir address, one plain mailbox.
nameOptional: how the team and the portal show them.
iat, expSeconds 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.

SettingDefaultWhat it decides
Storagebrowser storagebrowser 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.
Window300 secondsHow far back the capture reaches: 10 seconds to 30 minutes.
Size12 MiBThe replay's cap, 1 to 64 MiB. Past it the oldest part goes, and the replay is marked truncated.
Classesinteractions, console, network, errorsinteractions — 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, balanced input values, full nothing. 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.

ActionWhoWhat happens
Inspectworkspace adminWhat was written, the marks, the console, the requests and the replay.
Redactworkspace adminDrop entries or the replay before release.
Approveworkspace adminThe 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.
Rejectworkspace adminThe 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.

On this page