@tra-bilisim/report-issue
v0.2.18
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)
- 🎥 Browser-tab screen recording (current-tab-focused Chrome/Edge picker, 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
Current package version: 0.2.18.
npm install @tra-bilisim/report-issuePeer deps: react and react-dom (>=18). axios is only needed if you use the
axios network-logging adapter.
Console and global error capture
With captureConsole enabled (the default), the provider records console.log,
console.info, console.warn, console.error, uncaught page errors
(window.onerror), and unhandled promise rejections. The report stores the last
200 entries after the provider mounts; access tokens and Bearer values are
redacted before storage.
Browser DevTools diagnostics (such as forced-reflow warnings) and browser extension messages run outside the page context, so they cannot be collected by the widget.
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>
);
}Optional automatic capture
ReportIssueProvider keeps capture disabled by default. Set autoStartCapture
when opening the report dialog should immediately begin one of the consent-first
workflows:
<ReportIssueProvider config={adapter} autoStartCapture="screenshot">
<YourApp />
<FloatingReportButton />
</ReportIssueProvider>Allowed values are 'off' (the default), 'screenshot', and 'video'. Both
automatic modes show the relevant KVKK consent text before starting. Screenshot
mode then opens the annotation editor; video mode opens the browser capture
picker and temporarily hides the report dialog while recording.
Session consent for quick capture
By default, captureConsentMode="perCapture" displays the consent text before
every screenshot and recording. To keep the consent-first flow while allowing
quick repeat capture during the same browser tab session, use "session":
import { clearSessionCaptureConsent } from '@tra-bilisim/report-issue';
<ReportIssueProvider
config={adapter}
autoStartCapture="screenshot"
captureConsentMode="session"
captureConsentSessionKey={String(currentUser.id)}
>
<YourApp />
<FloatingReportButton />
</ReportIssueProvider>The first screenshot and the first video recording each show their own consent
text. After acceptance, only that capture type can start directly for the same
session, account scope, and consent-text version. The package stores only the
accepted flag, type, version, and timestamp in sessionStorage; it sends the
same consent record with every submitted report that contains a capture.
Pass a non-sensitive, stable account identifier as captureConsentSessionKey
when the application allows account switching in one browser tab. On logout or
when the user withdraws their preference, clear the scope explicitly:
clearSessionCaptureConsent(String(currentUser.id));Browser screen-recording APIs require a user gesture. A click on the floating Report an Issue button or on Record Video preserves that gesture and can start a session-approved recording directly. If a host opens the dialog programmatically, the user must click Record Video once.
Screen-recording picker behavior
The recorder is configured for the Chrome/Edge flow used by this package:
preferCurrentTab focuses the picker on the application tab and monitor surfaces
are excluded. In the supported browser versions targeted by the package, the
picker presents the tab in which the project is open rather than highlighting it
among unrelated tabs. As a defense-in-depth check, the package rejects a returned
screen or window surface and starts recording only for a browser-tab surface.
The capture picker is browser-owned UI. Hosts should test this behavior on the exact Chrome/Edge versions they support after browser upgrades.
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. On-screen text and supported form values are masked before capture; visual media and embedded content are not automatically redacted.': 'Bildirdiğiniz sorunu teşhis edebilmek için bu uygulama ekranının bir görüntüsü alınacaktır. Ekrandaki metinler ve desteklenen form değerleri görüntü alınmadan önce maskelenir; görsel medya ve gömülü içerikler otomatik olarak sansürlenmez.',
'To diagnose the issue you reported, a short screen recording of only this tab will be captured. On-screen text and supported form values are masked during recording; visual media and embedded content are not automatically redacted. You may temporarily reveal masked text and form values.': 'Bildirdiğiniz sorunu teşhis edebilmek için yalnızca bu sekmenin kısa bir ekran kaydı alınacaktır. Ekrandaki metinler ve desteklenen form değerleri kayıt sırasında maskelenir; görsel medya ve gömülü içerikler otomatik olarak sansürlenmez. Maskelenmiş metinleri ve form değerlerini geçici olarak görünür hale getirebilirsiniz.',
'Data that will be collected:': 'Toplanacak veriler:',
'A screenshot with supported text and form values masked': 'Desteklenen metin ve form değerleri maskelenmiş bir ekran görüntüsü',
'A screen recording with supported text and form values masked, limited to this application tab': 'Yalnızca bu uygulama sekmesiyle sınırlı, desteklenen metin ve form değerleri 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.',
'This field is required, please give your consent to continue.': 'Devam etmek için bu alanı onaylamanız gerekmektedir.',
'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!',
'Please try again in {minutes} minutes.': 'Lütfen {minutes} dakika sonra tekrar deneyin.',
'Please try again in {seconds} seconds.': 'Lütfen {seconds} saniye sonra tekrar deneyin.',
}{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)
Native Fetch adapter
attachFetchNetworkLogging(options?) wraps the global native fetch once and
feeds the shared report-issue log store. It has no axios dependency and covers
both centralized fetch wrappers and direct fetch calls in the host:
import { attachFetchNetworkLogging } from '@tra-bilisim/report-issue/adapters/fetch';
// Bootstrap/module scope only. Do not attach inside a React component.
const detach = attachFetchNetworkLogging({
// The default already removes the query string and fragment. This host rule
// additionally masks numeric/GUID-like path segments when necessary.
sanitizeUrl: url => url
.replace(/[?#].*$/, '')
.replace(/\/[0-9a-f-]{16,}(?=\/|$)/gi, '/[REDACTED]'),
// Optional: omit endpoints that should not enter a diagnostic report.
shouldLog: ({ url }) => !url.includes('/health'),
});
// Useful in tests/HMR. Repeated attaches are reference-counted.
detach();By default the adapter:
- records all fetch requests but strips query strings and fragments from logged URLs;
- injects
X-Request-Tracking-Idonly into relative or same-origin requests; - does not inject a custom header into third-party calls, avoiding accidental CORS preflights;
- never records request bodies, response bodies, cookies, authorization headers, or arbitrary headers;
- does not read/clone response bodies unless the host explicitly supplies
getMessage; - preserves the original
Responseand rethrows the original fetch rejection.
For dependency injection or tests, createInstrumentedFetch(fetchImplementation,
options?) returns the same fetch-compatible wrapper without changing
globalThis.fetch.
import { createInstrumentedFetch } from '@tra-bilisim/report-issue/adapters/fetch';
export const loggedFetch = createInstrumentedFetch(fetch, {
shouldInjectTrackingHeader: ({ url }) => url.startsWith('/api/'),
isErrorResponse: response => !response.ok,
});XMLHttpRequest traffic is separate from native fetch and is therefore not
intercepted. Hosts with centralized XHR upload helpers should instrument those
helpers with the core primitives shown below.
Axios adapter
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.
Socket log capture (optional)
The package intentionally does not depend on a real-time transport. Hosts can record safe socket-event summaries through the headless core; do not include the raw message payload:
import { pushSocketLog } from '@tra-bilisim/report-issue/core';
connection.on('OrderUpdated', message => {
pushSocketLog({
event: 'message-received',
hub: '/OrderHub',
method: 'OrderUpdated',
trackingId: message.trackingId,
});
// update local state
});High-frequency market frames should be filtered by the host before calling the
core. For example, Kointra omits TICKER and BOOK so the 100-entry socket ring
buffer remains useful for lifecycle, permission, settings, and error events:
if (messageType !== 'TICKER' && messageType !== 'BOOK') {
pushSocketLog({
event: 'message-received',
hub: '/t/adminws', // never include a JWT-bearing socket URL
method: messageType,
});
}For an exact HTTP-to-socket link, copy the originating HTTP request's
X-Request-Tracking-Id into the hub message as trackingId (or an equivalent
correlationId) on the server. The value is optional; without it, reports still
show a chronological network/socket timeline.
Custom transport primitives
Using XHR or another custom client? 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[] |
| socketLogs | JSON string | host-recorded SocketEntry[]; raw message payloads must not be included |
| visitedPages | JSON string | optional VisitedPage[]; omitted when the adapter does not provide entries |
| attachmentFormat | string | optional 'json' or 'html'; omitted when the default 'both' is used |
| 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; retryAfterSeconds?: number | null }.
Diagnostic attachment format
Generated console, network, socket, visited-page, and activity-timeline attachments
default to both JSON and HTML. Set attachmentFormat to limit them:
const adapter: ReportIssueAdapter = {
submit: async (payload, formData) => {
const res = await fetch('/api/report-issue', { method: 'POST', body: formData });
return { ok: res.ok };
},
attachmentFormat: 'json', // 'html' | 'both'
};retryAfterSeconds is optional and only relevant for rate-limit rejections (HTTP 429):
when present, the widget appends the remaining wait time to the error toast
("Too many report issue requests. Please try again later. Please try again in 10 minutes.").
If the adapter rejects instead of resolving on a 429, the widget also picks the value up
from the thrown error's response body or its Retry-After header.
This matches the Tra.Bilisim.ReportIssue.AspNetCore backend package's
POST /api/report-issue endpoint (legacy alias: POST /api/ReportIssue/Create)
— see ../backend/src/Tra.Bilisim.ReportIssue.AspNetCore/REPORT_ISSUE_INTEGRATION.md
for the server side.
Visited pages
Pass the pages visited during the current browser-tab session through the optional
visitedPages adapter field. Each entry contains the full URL and ISO-8601 visit time.
When the array is empty or omitted, the widget does not send the field and the backend
does not create visited-page attachments.
const adapter: ReportIssueAdapter = {
submit: async (payload, formData) => {
const res = await fetch('/api/report-issue', { method: 'POST', body: formData });
return { ok: res.ok };
},
visitedPages: [
{ href: 'https://app.example.com/customers/42', visitedAt: '2026-08-26T09:00:00.000Z' },
],
};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, supported page text is replaced with ●●●●●● and supported
input/textarea values are hidden before a screenshot is taken or a recording
starts. Masking is a data-minimization aid, not a guarantee that every sensitive
pixel or value on the page is removed.
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:
To keep the normal application UI masked too (for example, to test masking without
starting a screenshot or recording), set alwaysMask to true. The mask is
applied when ReportIssueProvider mounts and remains active until it unmounts.
const adapter: ReportIssueAdapter = {
submit: ...,
alwaysMask: true,
};alwaysMask defaults to false. It uses the same rules and exclusions as capture
masking, and it is restored automatically after a screenshot or recording ends.
What is and is not masked
The built-in mask covers:
- ordinary DOM text nodes, including text added while a recording is in progress;
- non-empty
inputandtextareavalues, except for the control types listed below; - number inputs, which are temporarily rendered as text controls so their values can be obscured.
The built-in mask does not inspect or redact:
- pixels or content inside
img,picture, inline/externalsvg,canvas, orvideoelements; - CSS background images, generated
::before/::aftercontent, or text drawn by charting and canvas libraries; - content inside iframes or shadow roots;
- the visible state/value of
hidden,checkbox,radio,file,button,submit,reset,image,range,color, andpasswordinput controls; - content deliberately opted out with
data-report-mask-ignore,data-report-mask-text-ignore, or matchingmaskSelectorsentries.
Review pages that contain profile photos, uploaded document previews, charts,
signatures, QR codes, account data rendered to canvas/SVG, or third-party iframe
content. Hide or replace those elements before capture, exclude them from
screenshots with data-report-ignore-capture, or disable the capture feature for
that page. Test screenshots and recordings against representative sensitive data;
the package cannot determine whether arbitrary visual content is personal data.
Masking controls (data attributes)
data-report-mask-ignore— keep text in this subtree visible. For screen recording, place it directly on nestedinput/textareacontrols whose values must also remain visible.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 against the element itself when
masking text and against ancestors when masking screenshot input values. During a
screen recording, an ignore selector must also match an input/textarea
directly to keep that control's value visible. Prefer a selector specific enough
to avoid unintentionally revealing real user data elsewhere on the page.
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/adapters/fetch— native fetch network-logging adapter@tra-bilisim/report-issue/styles.css— required stylesheet
Build
npm install
npm run build # tsup → dist/ (esm + d.ts + index.css)
npm run dev # tsup --watch
npm run typecheck # tsc --noEmitLicense
MIT — see LICENSE.
