@voxket-ai/qa-reporter
v0.1.1
Published
QA-only bug and feedback reporter for Voxket frontends. Tree-shaken out of production builds.
Readme
@voxket-ai/qa-reporter
QA-only bug and feedback reporter for Voxket frontends. Captures the context that
makes a report actionable — the failing request, the console error, and the source
file of the clicked component — and posts it to voxket-sentinel.
This package must never run in production. That is enforced by the build, not by a runtime flag. See below.
How the production guarantee works
initQaReporter() is safe to call unconditionally from app layout code. In a
production build it compiles down to a function that returns a no-op handle:
var e={enabled:!1,open(){},close(){},destroy(){}};
async function o(r){return e}That is the entire production entry — ~660 bytes, with the capture runtime and the whole widget UI nowhere in it.
Three things combine to make that true:
- The gate is a single comparison against
process.env.NEXT_PUBLIC_VOXKET_ENV. Next inlinesNEXT_PUBLIC_*as string literals at build time, so the comparison folds to a constant and the early return becomes unconditional. - The runtime is reached only through dynamic
import(). It is emitted as a separate chunk, so it is never part of the entry the browser downloads. - The folded gate makes that
import()unreachable, so the chunk is never fetched. Under webpack the dead branch is detected at parse time and the chunk is not even emitted.
Two rules that keep this working
- Keep the gate inline and single-term. Both of these broke it during
development, and both shipped the runtime to production:
&& !config.forceEnable— a runtime value makes the condition unfoldable.&& process.env.VOXKET_ENV !== "qa"— looks equivalent, but Next only inlinesNEXT_PUBLIC_*into client bundles, so the second term stays a runtime lookup.
- Never statically import the runtime from
index.ts.
There is deliberately no forceEnable escape hatch. For local development, set
NEXT_PUBLIC_VOXKET_ENV=qa.
test/gate.test.ts asserts all of this against a real production bundle, including
a counter-test that the runtime is reachable when the environment is qa — so a
gate broken permanently closed fails too.
Usage
npm install @voxket-ai/qa-reporter// app/layout.tsx
"use client";
import { useEffect } from "react";
import { initQaReporter } from "@voxket-ai/qa-reporter";
useEffect(() => {
const reporter = initQaReporter({
endpoint: "https://sentinel.qa.voxket.ai/api/v1/reports",
product: "voxket-web-2.0",
ingestToken: process.env.NEXT_PUBLIC_VX_INGEST_TOKEN,
reporter: { email: session?.user?.email },
releaseSha: process.env.NEXT_PUBLIC_RELEASE_SHA,
});
return () => {
reporter.then((r) => r.destroy());
};
}, []);Set NEXT_PUBLIC_VOXKET_ENV=qa in the QA environment only. ingestToken is the
shared QA ingest token: once the service has one configured (as it does in QA),
ingest returns 401 without a matching x-voxket-ingest-token header.
The widget
initQaReporter() mounts a floating "Report an issue" trigger. Opening it gives a
form for type, severity, title and description, plus "Point at the component",
which is the part that makes reports actionable.
The UI lives in a shadow root. That is not cosmetic: this runs inside apps we do not control, so without isolation the host's global CSS restyles our form (Tailwind preflight alone would flatten every control) and our CSS leaks into the app under test — meaning the tool changes the thing it is supposed to be observing.
The picker has three properties worth knowing about, each with a test:
- It cannot pick itself. Our chrome is hidden for the duration of each hit test rather than filtered out afterwards, because filtering would miss anything the host app renders above us at the same coordinates.
- The click never reaches the app. Listeners run in the capture phase and stop propagation, so picking a "Delete" button does not delete anything.
- It is always escapable. Escape, right-click and cancel all tear down, and teardown is idempotent. A picker that traps the page is worse than no picker.
Feature requests cannot be submitted without acceptance criteria. That is enforced at the keyboard, while the person still has the context in their head — a server-side rejection ten seconds later is far too late for them to supply it.
Screenshots
Screenshot capture is injected, not bundled:
initQaReporter({
endpoint: "...",
product: "voxket-web-2.0",
captureScreenshot: async () => {
const html2canvas = (await import("html2canvas")).default;
const canvas = await html2canvas(document.body);
return {
mimeType: "image/png",
data: canvas.toDataURL("image/png").split(",")[1],
width: canvas.width,
height: canvas.height,
};
},
});Both available approaches have real costs: html2canvas is ~50KB and mis-renders
complex CSS, while getDisplayMedia prompts on every use and can capture
unrelated tabs. Forcing either on every consumer is worse than letting the app
that knows its own rendering decide. Omit the option and the checkbox simply
captures nothing — the picker already supplies the component, its computed
styles and its source location, which is what actually makes a visual report
actionable.
What gets captured
| Field | Why it matters |
|---|---|
| network with request/response bodies | The strongest FE-vs-BE signal. A 5xx in the trace settles the routing question before a model is involved. |
| console errors + stacks | Usually names the failing module directly. |
| target.source | file:line:column of the clicked component, stamped by the build plugin. Tells the agent which file to open instead of making it guess. |
| target.computedStyles | Makes "the spacing looks wrong" a diffable observation. |
| fingerprint | Dedupe key, so five people hitting one broken page produce one ticket. |
Capture is always-on with bounded ring buffers, because bugs get noticed seconds after the failing request — starting capture when someone decides to report is too late.
Credential redaction
Bundles include headers and payloads, get attached to Jira tickets the whole team can read, and are embedded into the cortex knowledge store. So redaction happens in the browser, before anything is transmitted:
- Sensitive header and body keys (
authorization,cookie,*token*,*secret*,session,password, …), matched case-insensitively at any nesting depth. - Credential-shaped values regardless of key — bearer tokens, JWTs, and common provider key prefixes.
- Query-string and userinfo credentials in URLs.
Add redactKeys: ["tenantPin"] for anything domain-specific. Payloads are also
depth-limited, cycle-safe and truncated so a bundle cannot balloon on a large
response.
Development
npm install
npm test # includes the production-bundle safety assertions
npm run build
npm run typecheckStatus
Capture spine, widget UI and element picker are all in. target.source is
populated once @voxket-ai/qa-source-plugin is applied to the app's build.
Adding the full UI did not change the production guarantee: the entry is still ~660 bytes with no runtime markers, because everything above lives behind the dynamic import.
