Add it to your app
Two lines anywhere in your page. project is your board's address (https://ozvi.io/acme), token-url an endpoint of your app that says who is signed in.
<script src="https://ozvi.io/widget.js" defer></script>
<ozvi-widget project="acme" token-url="/api/ozvi-token" app-version="2.15.0"></ozvi-widget>Allow your app's address in the board's settings (admin → Project → Your app's addresses); elsewhere the widget won't work. React and Vue components, the JavaScript API (Ozvi.open("bug"), events) and the inline roadmap and changelog are described in the widget README.
Sign people in
Your backend tells Ozvi who is signed in: an endpoint of your app (the widget's token-url) returns { "token": "…" }, a short-lived token signed with the board's SSO secret (admin → Sign-in → Turn on SSO). No second password and no Ozvi account for your users. Keep the secret on the server, never in the browser.
In Node.js, @ozvi/sdk (MIT, no dependencies) signs it. The token is a standard JWT signed with HMAC-SHA256, so any other language makes it with its standard library. Fields: id (required, stable id in your app), email, name, role (member, team or admin), locale and traits (company, plan, mrr in cents… shown to your team only).
import { signOzviToken } from "@ozvi/sdk";
// Express; in Next.js the same inside app/api/ozvi-token/route.ts
app.get("/api/ozvi-token", requireLogin, (req, res) => {
res.json({ token: signOzviToken(
{ id: req.user.id, email: req.user.email, name: req.user.name,
traits: { company: req.org.name, plan: req.org.plan } },
process.env.OZVI_SSO_SECRET) });
});import base64, hashlib, hmac, json, os, time
def ozvi_token(user: dict, secret: str = os.environ["OZVI_SSO_SECRET"]) -> str:
b64 = lambda o: base64.urlsafe_b64encode(json.dumps(o, separators=(",", ":")).encode()).rstrip(b"=").decode()
now = int(time.time())
body = f'{b64({"alg": "HS256", "typ": "JWT"})}.{b64({**user, "iat": now, "exp": now + 600})}'
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest()
return f"{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"function ozvi_token(array $user, ?string $secret = null): string {
$secret ??= getenv('OZVI_SSO_SECRET');
$b64 = fn($s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$now = time();
$body = $b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])) . '.'
. $b64(json_encode($user + ['iat' => $now, 'exp' => $now + 600], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $body . '.' . $b64(hash_hmac('sha256', $body, $secret, true));
}Keep it short-lived
exp. Ten minutes is plenty: the widget asks your endpoint for a fresh one whenever it needs it. Tokens carry your users' e-mails, so don't paste them into online decoders.What a bug report captures
Everything is kept in memory only, masked in the browser before it is kept, and leaves the page only when the person sends a report. The network log, steps and session links are visible to your team only; the Ozvi server masks and bounds them again.
| What | Detail | Never |
|---|---|---|
| Page | Address (masked), app version, browser, window size, language | |
| Screenshot | The visible part of the page without the widget, with private data covered (below). The reporter sees it first, can mark it up or remove it. | Form values, e-mails, phone numbers, IBANs, card numbers, tokens, data-ozvi-mask |
| Console errors | Last 20, 300 characters each | |
| Network log | Last 30 requests: method, URL, status, duration, size | Headers (no Authorization, no Cookie); bodies unless you turn them on |
| Steps | Last 30 page changes and clicks on buttons and links, by name | What anyone typed |
In URLs and logged text, values of parameters named like token, password, key, session or card, and any JWT, Bearer credential, e-mail address, IBAN or card number become ***.
Screenshot privacy
The screenshot is drawn from a copy of the page that the widget edits first. The page the person sees is never touched, and what is covered never becomes pixels: covered text is drawn transparent on a solid box, covered images are not drawn at all. Without any setup, Ozvi covers:
- the values of every
input,textarea,selectand editable area (checkboxes and radio buttons keep their state); - e-mail addresses, phone numbers, IBANs, card numbers and tokens anywhere in the text; the rest of the sentence stays readable;
- everything inside elements marked
data-ozvi-mask.
If the copy can't be masked, the report is sent without a screenshot, never with an unmasked one.
Mark what can't be recognised: data-ozvi-mask
Names, notes, diagnoses or amounts don't have a recognisable shape. Mark them and the whole element is covered, its images too:
<td data-ozvi-mask>Jana Kováčová</td>
<p data-ozvi-mask>{{ client.note }}</p>Cover everything
For apps where almost everything on screen is private (health care, finance, HR), cover all text and images and reveal only what helps your team understand the page, such as navigation and headings:
<script>window.OzviConfig = { mask: "all" };</script>
<nav data-ozvi-unmask>…</nav>
<h1 data-ozvi-unmask>Invoice settings</h1>Your privacy policy
Configuration
Set window.OzviConfig before widget.js loads, or call Ozvi.configure() later.
window.OzviConfig = {
mask: "auto", // "all" = cover all text and images except [data-ozvi-unmask]
network: true, // false = no network log
networkBodies: false, // true = masked bodies of failed requests (max 2 000 characters)
actions: true, // false = no steps
maskRequest(entry) { // last word on every logged request; return null to drop it
return { ...entry, url: entry.url.replace(/\/customers\/\d+/, "/customers/:id") };
},
};More
The full reference (JavaScript API, events, React and Vue, inline roadmap and changelog, strict CSP with a nonce, the screenshot editor) is in the widget README. Try the widget in a sample app on the demo.