Bug reports
Annotated screenshots, attachments, console and network diagnostics, drafts and the offline outbox.
Report a bug (or B) opens a dialog that captures the page, lets the reporter mark it up and sends it with the technical context a developer needs. The form, the screenshot engine (SnapDOM) and the annotation editor are separate chunks, fetched the moment someone hovers the launcher, so they cost nothing on page load.
The report
| Field | Notes |
|---|---|
| Screenshot | Taken automatically when the dialog opens, then annotated in place. |
| Title | Optional, up to 200 characters. |
| Description | Required. |
| Severity | Low, Medium (default), High or Critical — stored as the item's priority (Critical → urgent). |
| Custom questions | Optional extra fields the project added in Project → Form, under "More details". Required ones block sending until answered. |
| Attachments | Up to 3 files. |
| Name and email | Optional, remembered in the browser; collapsed to one line once the name is known (typed before, or from identify()). |
| What we'll send | A summary of the diagnostics that go with the report. |
Capture modes
Switch with the bar above the screenshot:
| Mode | What it captures |
|---|---|
| Visible area | The viewport, as the reporter sees it (default). |
| Full page | The whole scrollable document, scaled down if needed (canvas limits: 16,000 px per side, 36 megapixels). |
| Element | Click one element on the page to capture just that. |
| Screen share | One frame of a browser screen share the reporter approves. Pixel-exact, including cross-origin iframes and canvases. Only offered where the browser supports getDisplayMedia — desktop browsers on https. |
Screenshots are sent as WebP (JPEG where the browser can't encode WebP). The widget itself and anything marked
data-pinwren-ignore are left out; masked elements appear as solid grey blocks
(Privacy & masking).
Screenshot quality
The project's Capture → Screenshot quality decides how sharp screenshots are:
| Quality | Captured at | Sent as | Use it for |
|---|---|---|---|
| High | Up to 3× density | WebP/JPEG quality 95 | Design review, fine text, retina detail |
| Standard | Up to 2× density | WebP/JPEG quality 85 (default) | Most sites |
| Low | 1× density | WebP/JPEG quality 60, at most 1280 px wide | Slow connections, very long pages |
Unless you turn off Let reporters reduce quality, the report form also has a Reduce capture quality switch: that one screenshot is sent as Low, whatever the project's setting. Pinned comments with a screenshot follow the project's quality.
If DOM capture fails or takes longer than 8 seconds, the dialog says so and offers Capture with screen share or Send without screenshot.
Annotation editor
| Tool | Key | Notes |
|---|---|---|
| Box | R | A rectangle outline. |
| Ellipse | O | |
| Arrow | A | |
| Draw | P | Freehand strokes. |
| Highlight | H | A wide, see-through marker stroke for text and areas. |
| Text | T | Click to place and type; Enter or clicking away commits, Esc discards. |
| Step | N | Click to drop a numbered marker (1, 2, 3 …) for steps to reproduce. |
| Hide | B | Pixelates the area — irreversibly, unlike a blur, so it is safe for personal data. |
| Crop | C | Shown as a dimmed surround; pixels are cut when the report is sent. |
Five colours (red, amber, green, blue, near-black), three sizes (small, medium, large), and undo / redo with
Ctrl/⌘ + Z and Ctrl/⌘ + Shift + Z (or Ctrl + Y).
Annotations are sent as a vector layer over the screenshot, not burnt into it: the dashboard draws them on top and can switch them off to show what was underneath. Hide and Crop are the exception — they change the image itself before it leaves the browser, so hidden data is never uploaded.
The screenshot also records the viewport it shows (width, height, scroll position and capture mode), which Page review uses to place every thread of the page on it. A cropped screenshot or an element capture is not used for that.
Attachments
Drop files on the dialog, paste them (a pasted image gets a readable name), or browse:
- at most 3 files, each up to 5 MB;
- PNG, JPEG, WebP, GIF or PDF.
The server checks each file's bytes against its declared type and rejects mismatches with
400 INVALID_ATTACHMENT.
Diagnostics
Collected from the moment the widget loads, sized by the project's capture settings, and sent with bug reports — and with comments whose type is set to Bug. Other comments carry the page, the anchor and the browser environment only.
Console
capture.console | Recorded |
|---|---|
off | Nothing. |
errors | console.error, uncaught errors and unhandled promise rejections. |
warnings (default) | The above plus console.warn. |
all | Every level: error, warn, info, log, debug. |
The last 100 entries are kept, 4,000 characters each. Objects are serialised safely (circular references,
DOM nodes as <tag#id>). The wrappers call your original console methods first and are removed by
destroy().
Network
With capture.network: "failures" (default) the widget records fetch and XMLHttpRequest calls that
failed (status ≥ 400 or no response) or took longer than 3 seconds — method, URL, status and duration, the
last 50. Request and response bodies are never captured. Query parameters whose name looks sensitive
(token, key, secret, password, auth, session, code, sig) are replaced with [redacted], and
same-origin URLs are stored as a path. The widget's own calls to Pinwren are ignored.
Environment and timing
Every item records the browser, OS, user agent, viewport, device pixel ratio, language, time zone and referrer. Bug reports add page timing: time to first byte, DOMContentLoaded, load, first and largest contentful paint. In privacy mode the user agent shrinks to “Chrome on Windows”, the time zone, referrer and timing are left out, and the console is capped at errors.
Error prompt
Off by default. Switch on Offer a report after an error (Widget tab › Behaviour, widgetConfig.errorPrompt) and
the widget listens for uncaught errors and unhandled promise rejections on your pages, the way a crash-report dialog
does. When one happens it shows a small, non-blocking toast, “Something went wrong. Send a report?”, in the corner.
Send report opens the usual bug-report form with the title filled in from the error message; the error and its
stack are added to the report's console log (even when console capture is off) and to metadata.error. Dismissing
the toast sends nothing, and nothing is captured about an error the visitor does not choose to report.
- Rate limited. At most three prompts per browser session, at least 30 seconds apart, and an identical error (same message and first stack frame, ignoring numbers) is offered only once. The toast disappears by itself after 12 seconds.
- Only where the widget is. No prompt while the widget is hidden by its visibility rules or the viewport rule, while a report is already open, or when bug reports are switched off.
- Privacy mode. Never offered, and
/w/configreturnserrorPrompt: false: the error text and stack are page content. The consent notice still appears in the form, as for any report. - No cost when off. The listener is a separate lazy chunk (
widget-errors.js, under 2 KB gzip) that is only requested for projects with the switch on. Errors from cross-origin scripts that the browser reports only as “Script error.” and errors thrown by the widget's own files are ignored.
Session and trace links
If the page already runs Sentry, PostHog, LogRocket or Datadog RUM, the report records where to find the visitor's session in those tools. The widget only reads globals those SDKs already expose. It never loads them, calls the network or starts a recording, and it validates every value before sending:
| Tool | Read from | Stored in metadata.observability |
|---|---|---|
| Sentry | window.Sentry (getCurrentScope().lastEventId(), getReplay()) or the v7 __SENTRY__ hub | sentry: { eventId, replayId, host } (host from the DSN) |
| PostHog | posthog.get_session_id(), posthog.get_session_replay_url() | posthog: { sessionId, replayUrl } |
| LogRocket | LogRocket.sessionURL | logrocket: { sessionUrl } |
| Datadog RUM | DD_RUM.getInternalContext(), DD_RUM.getInitConfiguration().site | datadog: { sessionId, applicationId, site } |
In the dashboard the item's Details show Open in Sentry / PostHog / LogRocket / Datadog buttons. Sentry and
Datadog links are built from the stored ids (Sentry resolves the organisation when you are signed in; self-hosted
Sentry works through the DSN host); PostHog and LogRocket use the URL the SDK gave. Only https: links without
credentials are ever rendered, ids and hosts must be plain tokens, and hovering a button shows the domain it opens.
Any site can supply the same shape itself, for another tool or when the SDK is not reachable from the page, with
Pinwren.setMetadata({ observability: { sentry: { eventId } } }); detected values win
over yours. Nothing is read in privacy mode.
Session replay
Session replay is off by default. Turn it on in the project's Capture tab (Session replay › Record
replays, with a Length slider for 30–120 seconds); that sets capture.replay.enabled and
capture.replay.seconds. When enabled, the widget lazily loads its replay chunk (rrweb's recorder) and keeps a rolling
buffer of the last capture.replay.seconds (30–120, default 60) in memory. Nothing leaves the page unless a
bug report is sent: the report form lists Session replay · last 60 s with a switch, so the reporter can
leave it out. The recording is gzipped in the browser and stored with the report (up to 10 MB) — only when the
project has replay turned on; anything that isn't gzip or is over 10 MB is refused with 400 INVALID_REPLAY.
The team watches it in the item's Replay tab in the dashboard, with console errors, warnings and failed
requests marked on the timeline (click a marker to jump there). It can also be downloaded with
GET /api/v1/assets/:id, and its steps feed the reproduction context for AI agents.
Replay masks every input, does not record the widget, .pinwren-block or [data-pinwren-ignore] elements,
and replaces the text of .pinwren-mask, [data-pinwren-mask] and the project's mask selectors. Privacy mode
turns replay off unless the page explicitly allows it with the allowReplayInPrivacyMode init option (or
data-allow-replay-in-privacy-mode) — then
all text is masked, and handling consent is up to you.
Screen recording
The attachment area also has a Record screen button: the reporter records their tab, optionally with voice narration, reviews the clip and sends it with the report. See Screen recording.
Drafts
An unsent report — text, severity, screenshot with its annotations, attachments — is saved in the browser's IndexedDB as the reporter types. Closing the dialog keeps it (“Draft saved”), and the next Report a bug offers to Resume or Start over. A draft survives reloads; it is deleted when the report is sent.
Offline outbox
A report that fails for a transient reason — offline, a server error, 429 or a timeout — goes into an
IndexedDB outbox instead of failing:
- retried with exponential backoff (2 s, 4 s, 8 s … capped at 5 minutes, with jitter), whenever the browser comes back online, and on the next page load;
- the launcher shows a queued state and the menu an entry such as 1 report waiting to send; choosing it retries immediately;
- each retry reuses the report's
clientId, so the server never stores a duplicate; - items older than 7 days, or that the server refuses for good (a
400or403on retry), are dropped and theerrorevent fires.
When a queued report gets through, the visitor sees a toast and the submit event fires. On the first
attempt, errors that won't fix themselves (a 400 or 403) are shown in the dialog instead of being queued.
What the server receives
POST /w/feedback with the project key header, for example:
{
"clientId": "3f0c…",
"kind": "bug",
"title": "Checkout button does nothing",
"body": "Clicking Pay shows a spinner forever.",
"priority": "high",
"page": { "url": "https://example.com/checkout", "title": "Checkout" },
"environment": {
"userAgent": "…",
"browser": "Chrome 141",
"os": "macOS",
"viewport": { "w": 1440, "h": 900 },
"dpr": 2
},
"diagnostics": {
"console": [{ "level": "error", "message": "TypeError: …", "at": 1761091200000 }],
"network": [
{ "method": "POST", "url": "/api/pay", "status": 500, "durationMs": 412, "at": 1761091200000 }
]
},
"metadata": { "performance": { "ttfbMs": 120, "lcpMs": 1450 } },
"customFields": { "order_number": "ORD-9001", "plan": "pro" },
"screenshot": "data:image/webp;base64,…"
}The response is 201 { "feedback": { "id", "number", "reporterToken" } }.
Custom questions
If the project has custom fields for bug reports, GET /w/config lists them under
widget.customFields (reporter fields only; internal fields and personal-data flags never leave the server) and the
report form asks them. They load in their own small chunk, widget-fields.<hash>.js (under 5 KB gzip), only when the
project has questions, so the report chunk and widget.js stay as small as before. Answers are checked in the form
(required, length, number range, option, email, http(s) link) and again by the server, which stores only the answers
to fields that apply and rejects invalid ones with a 400 the form shows. Labels and options come from the project,
translated per language by the project; the form's own messages are in all widget languages. Answers are kept in the
draft and in the offline outbox like the rest of the report.
Limits
The server checks each limit before it parses or stores anything:
| Limit | Answer |
|---|---|
The report must declare its size with Content-Length | 411 LENGTH_REQUIRED |
| Whole report under 72 MB (the proxy must allow it too) | 413 PAYLOAD_TOO_LARGE |
| Replies and status changes up to 64 KB | 413 PAYLOAD_TOO_LARGE |
page.url must be http:// or https:// | 400 (validation) |
| Attachments: 3 files of up to 5 MB; replay: gzip, up to 10 MB | 400, 400 INVALID_REPLAY |
| Screen recording: WebM or MP4, up to 16 MB, within the project's length | 400 INVALID_RECORDING |
Reports per minute from one address (WIDGET_RATE_LIMIT_PER_MIN, default 10, or the project's own) | 429 RATE_LIMITED |
Reports per minute to the project from everyone (WIDGET_PROJECT_REPORTS_PER_MIN, default 120) | 429 PROJECT_RATE_LIMITED |
Stored files per project and hour (WIDGET_PROJECT_MB_PER_HOUR, default 500 MB) | 429 PROJECT_STORAGE_LIMITED |
The widget treats every 429 as temporary and queues the report in its outbox.
Replays in the dashboard
The dashboard plays a replay inside its own Content-Security-Policy, so a recorded page can't load anything from other servers when it is replayed: no remote images, fonts, stylesheets or scripts, and nothing is fetched from the recorded site. A replay therefore shows the page's structure and text as captured, with missing remote images and web fonts. The viewer also stops unpacking a replay past 64 MB of decompressed data.