@tra-bilisim/report-issue
v0.2.1
Published
Headless-core + React UI "Report an Issue" widget: masked screenshot/annotation, tab-only screen recording, console/network log capture and KVKK consent.
Readme
@tra-bilisim/report-issue
Self-contained "Report an Issue" widget for React apps. Zero Tailwind, zero
design-system coupling — bring your own submit, everything else has a default.
- 📸 Masked screenshot with an annotation editor (arrows, boxes, text, highlighter, crop)
- 🎥 Tab-only screen recording (KVKK-safe, current tab only, hard max duration)
- 🕵️ Console + network log capture (ring buffer)
- 🔒 KVKK explicit-consent gate before any capture, with versioned consent records
- 🎨 Standalone Radix + scoped CSS (
.rpi-*,--rpi-*), dark mode via a.darkclass
Install
npm install @tra-bilisim/report-issuePeer deps: react and react-dom (>=18). axios is only needed if you use the
axios network-logging adapter.
Usage
import {
ReportIssueProvider,
FloatingReportButton,
type ReportIssueAdapter,
} from '@tra-bilisim/report-issue';
import '@tra-bilisim/report-issue/styles.css';
const adapter: ReportIssueAdapter = {
// The ONLY required field. Persist the report however you like.
submit: async (payload, formData) => {
const res = await fetch('/api/report-issue', { method: 'POST', body: formData });
return { ok: res.ok };
},
// Everything below is optional.
getCategories: async () => [{ label: 'UI', value: 1 }, { label: 'Data', value: 2 }],
getMetadata: () => ({ userId: currentUser.id, roles: currentUser.roles }),
getCurrentUrl: () => window.location.href,
pageOptions: [{ value: 'https://app/x', label: '/x' }],
pageInputMode: 'select', // 'select' (default, uses pageOptions) | 'input' (free-text URL)
maskCapture: true, // default true (KVKK-safe); set false for unmasked screenshots/recordings
screenshotCapture: { mode: 'bounded', maxViewportHeights: 2 }, // default: cap long pages at 2x viewport height
environmentResolver: url => (url.includes('localhost') ? 'Locale' : 'Prod'),
t: key => i18n.translate(key), // English keys are the default fallback text
locale: 'tr',
toast: { success: msg => toast.success(msg), error: msg => toast.error(msg) },
};
function App() {
return (
<ReportIssueProvider config={adapter}>
<YourApp />
<FloatingReportButton />
</ReportIssueProvider>
);
}Dark mode
The widget is light by default and switches to dark when a .dark class is
present on <html>, <body>, or any ancestor of the widget (the dialog/floating
button render into a portal on document.body, so the class needs to be on
document.body itself or higher — not on some inner <div> further down).
document.documentElement.classList.add('dark'); // dark
document.documentElement.classList.remove('dark'); // lightThere is no automatic prefers-color-scheme detection — the widget never
guesses from the OS/browser setting. This is intentional: most hosts already run
a .dark-class-based theme system (Tailwind's darkMode: 'class', shadcn/ui,
etc.) where "no .dark class" deliberately means light, independent of the
visitor's OS setting. Auto-following the OS would silently fight that and show
dark even when the host's own UI is light. If your toggle logic only ever adds
.dark conditionally and calls classList.remove('dark') for light — as most
setups do — you don't need to do anything else; just make sure .dark is never
left on <html>/<body> when the app itself is in light mode.
If you need to force light inside a .dark-classed ancestor you don't control
(rare — e.g. a 3rd-party wrapper always adds .dark), add an explicit .light
class; it wins over .dark.
Localization (i18n)
The widget never bundles its own i18n library — it calls t(key) from your adapter
for every user-facing string. The key itself is the English fallback text, so if
you omit t, the UI renders in English as-is.
Wire it to whatever i18n system you already use:
const adapter: ReportIssueAdapter = {
submit: ...,
t: key => i18n.translate(key), // e.g. react-i18next: (key) => i18next.t(key)
locale: 'tr',
};Below is the complete list of keys used by the widget, with Turkish
translations. Drop this straight into your translation resource file (adjust the
shape to whatever your i18n.translate/t implementation expects — this is a plain
key → value map):
{
// Annotation editor toolbar
'Select': 'Seç',
'Draw': 'Çizim',
'Line': 'Çizgi',
'Arrow': 'Ok',
'Rectangle': 'Dikdörtgen',
'Circle': 'Daire',
'Text': 'Metin',
'Highlight': 'Vurgula',
'Crop': 'Kırp',
'Thickness': 'Kalınlık',
'Undo': 'Geri al',
'Redo': 'Yinele',
'Cancel': 'İptal',
'Confirm': 'Onayla',
// Floating button / dialog title
'Report an Issue': 'Sorun Bildir',
'Describe the problem and attach screenshots or a screen recording.': 'Sorunu açıklayın ve ekran görüntüsü veya ekran kaydı ekleyin.',
// Attachments
'Attachments': 'Ekler',
'Take a Screenshot': 'Ekran Görüntüsü Al',
'Record Video': 'Video Kaydet',
'{name} exceeds the size limit.': '{name} boyut sınırını aşıyor.',
'You can attach at most {count} files.': 'En fazla {count} dosya ekleyebilirsiniz.',
'Failed to capture screenshot.': 'Ekran görüntüsü alınamadı.',
// Screen recording
'Screen recording is only supported on Chrome and Edge browsers.': 'Ekran kaydı yalnızca Chrome ve Edge tarayıcılarında desteklenir.',
'Only this application tab can be recorded. Please share this tab.': 'Yalnızca bu uygulama sekmesi kaydedilebilir. Lütfen bu sekmeyi paylaşın.',
'Screen recording permission was denied.': 'Ekran kaydı izni reddedildi.',
'Stop': 'Durdur',
// Recording / screenshot consent dialog (KVKK)
'Screenshot Consent': 'Ekran Görüntüsü Onayı',
'Screen Recording Consent': 'Ekran Kaydı Onayı',
'Before the screenshot is taken, please review which data is collected and give your consent.': 'Ekran görüntüsü alınmadan önce, hangi verilerin toplandığını inceleyip onayınızı verin.',
'Before the recording starts, please review which data is collected and give your consent.': 'Kayıt başlamadan önce, hangi verilerin toplandığını inceleyip onayınızı verin.',
'To diagnose the issue you reported, a screenshot of this application screen will be captured. Sensitive on-screen data is masked before capture.': 'Bildirdiğiniz sorunu teşhis edebilmek için bu uygulama ekranının bir görüntüsü alınacaktır. Hassas veriler görüntü alınmadan önce maskelenir.',
'To diagnose the issue you reported, a short screen recording of only this tab will be captured. All on-screen data is masked during recording; you may temporarily reveal it.': 'Bildirdiğiniz sorunu teşhis edebilmek için yalnızca bu sekmenin kısa bir ekran kaydı alınacaktır. Kayıt sırasında tüm ekran verileri maskelenir; isterseniz geçici olarak görünür hale getirebilirsiniz.',
'Data that will be collected:': 'Toplanacak veriler:',
'A masked screenshot of this application screen': 'Bu uygulama ekranının maskelenmiş bir görüntüsü',
'A masked screen recording limited to this application tab': 'Yalnızca bu sekmeyle sınırlı, maskelenmiş bir ekran kaydı',
'Console and network logs of this session': 'Bu oturuma ait konsol ve ağ kayıtları',
'Session metadata (user, roles, browser, page)': 'Oturum meta verileri (kullanıcı, roller, tarayıcı, sayfa)',
'This data is used solely to diagnose and resolve the reported issue and is shared only with the relevant technical team. You may withdraw your consent by not submitting the report.': 'Bu veriler yalnızca bildirdiğiniz sorunu teşhis edip çözmek amacıyla kullanılır ve yalnızca ilgili teknik ekiple paylaşılır. Raporu göndermeyerek onayınızı geri çekebilirsiniz.',
'I have read the above and give my explicit consent to the processing and sharing of this data.': 'Yukarıdakileri okudum ve bu verilerin işlenmesine ve paylaşılmasına açıkça onay veriyorum.',
'Take Screenshot': 'Ekran Görüntüsü Al',
'Start Recording': 'Kaydı Başlat',
// Page field
'The page where the issue occurred': 'Sorunun oluştuğu sayfa',
'Current page': 'Geçerli sayfa',
'Enter the page where the issue occurred': 'Sorunun oluştuğu sayfayı girin',
'Please specify the page.': 'Lütfen sayfayı belirtin.',
// Category / description
'Category': 'Kategori',
'optional': 'opsiyonel',
'Description': 'Açıklama',
'Describe what happened...': 'Ne olduğunu açıklayın...',
// Select control
'Search': 'Ara',
'No results': 'Sonuç bulunamadı',
// Submit
'Submit': 'Gönder',
'An error occurred!': 'Bir hata oluştu!',
}{name} and {count} are literal placeholders — the widget does simple
.replace('{name}', ...) / .replace('{count}', ...) substitution itself, so keep
those tokens verbatim in your translated value.
Network log capture (optional)
attachAxiosNetworkLogging(instance, options?) installs request/response
interceptors on your axios instance that feed the shared report-issue log store.
The captured requests are attached to a report as networkLogs automatically.
Call it once, at module scope where you create your axios instance — not inside a React component (that would re-attach on every render):
// src/api/http.ts
import axios from 'axios';
import { attachAxiosNetworkLogging } from '@tra-bilisim/report-issue/adapters/axios';
export const http = axios.create({ baseURL: '/api' });
// One call — done. Every request through `http` is now logged.
attachAxiosNetworkLogging(http);attachAxiosNetworkLogging returns a detach function that removes the
interceptors (useful in tests or HMR):
const detach = attachAxiosNetworkLogging(http);
// ...later
detach();Options
Both options are optional — the defaults cover common API shapes.
attachAxiosNetworkLogging(http, {
// Pull a human-readable message out of the response body for the log entry.
// Default looks for { message | Message | errorMessage | ErrorMessage }.
getMessage: data => (data as any)?.error?.detail,
// Decide whether a response counts as an error in the log.
// Default: any status < 200 or >= 300.
isErrorResponse: (status, data) =>
status >= 400 || (data as any)?.success === false,
});Request correlation header
Each logged request is tagged with an X-Request-Tracking-Id header. If your
backend already sets/echoes this header, the adapter reuses it; otherwise it
generates one (rpi_<time>_<rand>). This lets you cross-reference a captured
request with your server logs.
Custom transport (no axios)
Using fetch or another client? Skip the adapter and call the core primitives
directly from @tra-bilisim/report-issue/core:
import {
beginNetworkRequest,
completeNetworkRequest,
failNetworkRequest,
} from '@tra-bilisim/report-issue/core';
async function loggedFetch(url: string, init?: RequestInit) {
const id = crypto.randomUUID();
beginNetworkRequest(id, init?.method ?? 'GET', url);
try {
const res = await fetch(url, init);
completeNetworkRequest(id, res.status, !res.ok);
return res;
} catch (err) {
failNetworkRequest(id, undefined, (err as Error).message);
throw err;
}
}Wire contract (submit)
submit(payload, formData) receives both a structured payload and a ready-to-POST
FormData. The FormData fields are the canonical contract your backend consumes:
| Field | Type | Notes |
|---|---|---|
| files | File[] (repeated) | screenshots / recordings / uploads |
| page | string | absolute URL |
| environment | string | from environmentResolver |
| categoryId | string | omitted when no category |
| description | string | omitted when empty |
| consoleLogs | JSON string | ConsoleEntry[] |
| networkLogs | JSON string | NetworkEntry[] |
| recordingConsent | JSON string | only when a recording is attached |
| screenshotConsent | JSON string | only when a screenshot is attached |
| metadata | JSON string | ReportMetadata (user, roles, UA, resolution, ts) |
submit must resolve to { ok: boolean; message?: string | null }.
Screenshot height
Long, scrollable documents are capped by default so the annotation editor does not receive an unreadably tall image or allocate a huge canvas. The default is:
const adapter: ReportIssueAdapter = {
submit: ...,
screenshotCapture: {
mode: 'bounded',
maxViewportHeights: 2,
},
};Modes:
bounded- capture from the user's current scroll position, capped bymaxViewportHeightsand optionallymaxHeightPx.viewport- capture only the visible viewport.fullPage- preserve the old behavior and capture the whole document when the page itself scrolls.
For an absolute hard cap, add maxHeightPx, for example
{ mode: 'bounded', maxViewportHeights: 2, maxHeightPx: 2400 }.
Masking (KVKK privacy)
By default, everything is masked: page text is replaced with ●●●●●● and
input/textarea values are hidden before a screenshot is taken or a recording
starts. This is the safe default — turn it off only if your app has no
sensitive on-screen data.
const adapter: ReportIssueAdapter = {
submit: ...,
maskCapture: false, // disable masking entirely — screenshots/recordings capture the real UI
};maskCapture is a single on/off switch for both capture types (screenshot and
screen recording); there's no separate flag per type. When true (default), the
per-element data attributes below give you fine-grained control over what stays
masked/visible within that overall mask:
Masking controls (data attributes)
data-report-mask-ignore— never mask this subtree (also excluded from the video mask)data-report-mask-text-ignore— keep text visible (still masks inputs)data-report-ignore-capture— exclude from screenshots entirelydata-report-mask-control/data-report-mask-value— force text masking
Masking controls for third-party markup (CSS selectors)
The attributes above require you to add them to your own JSX. If the element you
need to exclude is rendered by a component you don't control — a <legend> from a
form library, a chart tooltip, a portal from another package — you can't attach an
attribute to it. maskSelectors gives you the same four controls as CSS
selectors instead, so you can target it from outside without touching its markup:
const adapter: ReportIssueAdapter = {
submit: ...,
maskSelectors: {
ignore: ['legend', '.third-party-lib__tooltip'], // == data-report-mask-ignore
textIgnore: ['.some-lib-readonly-label'], // == data-report-mask-text-ignore
forceTextMask: [], // == data-report-mask-control
ignoreCapture: [], // == data-report-ignore-capture
},
};All four arrays are optional and additive — they extend the built-in attributes,
they don't replace them. Selectors are matched the same way the attributes are:
against the element itself when masking text (a <legend>Personal info</legend>
selector directly excludes its own text) and against ancestors when masking
inputs (ignore: ['.card'] protects inputs nested inside .card, not a .card
element that is itself an input). Prefer a selector specific enough to avoid
unintentionally un-masking real user data elsewhere on the page (e.g. 'legend' is
fine if you have no other sensitive <legend> elements; otherwise scope it further,
e.g. '.my-form legend').
Entry points
@tra-bilisim/report-issue— React provider, button, dialog, hooks, types@tra-bilisim/report-issue/core— headless, framework-agnostic engine@tra-bilisim/report-issue/adapters/axios— axios network-logging adapter@tra-bilisim/report-issue/styles.css— required stylesheet
Build
npm run build # tsup → dist/ (esm + d.ts + index.css)