# QBit QA Capture Optional, provider-pluggable QA bug capture for QQQ applications. Chosen users (an audience, or anyone with the QA mode permission who switches QA mode on) can record a bug with screen, console and network context; secrets and secure values are removed in the browser before anything is sent. [Jam](https://jam.dev) is the first provider. Settings, audiences, sanitizer rules and QA reports are standard QQQ tables, and every API response carries an `X-Request-Id` that reports collect, so a bug links straight to the server log lines. Design: [docs/DESIGN.md](docs/DESIGN.md). The UI contract is the `qa-capture` UI API extension 1.0.0, committed as [`src/main/resources/openapi/qqq-v1/qa-capture.yaml`](src/main/resources/openapi/qqq-v1/qa-capture.yaml); the QQQ Next UI (`QRun-IO/qqq-frontend-next`) implements the client side. ## Requirements - Java 21, QQQ 4.1 (built against `qqq-bom-pom` 4.1.0-RC.11). - `qqq-middleware-javalin` (the UI API extension, request ids, recorder page and webhooks). - An RDBMS backend for the four tables (PostgreSQL in production; the changelog runs as an ordinary application user). ## Install ```xml com.kingsrook.qbits qbit-qa-capture 0.1.0 ``` Include the changelog from the host's Liquibase master changelog (the QBit never runs DDL): ```yaml - include: { file: db/liquibase/qbit-qa-capture/qa_capture.changelog.sql } ``` Produce the QBit after the host's Admin app exists: ```java new QaCaptureQBitProducer() .withQBitConfig(new QaCaptureQBitConfig() .withBackendName("rdbms") .withEnvironment(System.getenv("APP_ENVIRONMENT")) // "prod": selects the settings row .withBackendVersion(buildVersion) // from the host's manifest .withParentAppName("admin") // nest under the Admin app .withJamWebhookSecret(System.getenv("QA_CAPTURE_JAM_WEBHOOK_SECRET")) .withRoleResolver(session -> identityProviderGroups(session))) // optional, for ROLE audience rows .produce(qInstance); ``` ## Tables | Table | What it holds | How rows get created | |---|---|---| | `qa_capture_settings` | One row per environment: provider, team id, reusable recording link id, mode (`OFF`, `AUDIENCE`, `SELF_SERVE`, `AUDIENCE_AND_SELF_SERVE`), extra blur selectors. | The host's migration or an administrator. Nothing is seeded. | | `qa_capture_audience` | Users (`USER`: user id or email) and roles (`ROLE`) who capture in the audience modes. Rows with source `QA_MODE` hold a user's self-serve QA mode switch when the host has no core key-value preference store; they never grant audience membership. | Administrators; the QA mode preference. | | `qa_capture_sanitizer_rule` | What captures redact: `HEADER`, `BODY_FIELD`, `QUERY_PARAM`, `VALUE_PATTERN`, `DROP_PATH`, `BLUR_SELECTOR`. Active rows are the rule set served to browsers. | Built-in rows from the changelog (insert-only); `SECURED_DATA` rows at startup for fields the instance marks secure; administrators. | | `qa_capture_report` | One reported bug: page, app, table, record, process, recent request ids, user, environment, build, node, browser, the Jam capture and the GitHub issue. | Report a bug (`POST /qqq/v1/qbit/qa-capture/report`) and unmatched provider webhooks. | The QQQ table names equal the SQL names. The only foreign key is `qa_capture_audience.settings_id` (cascade). Nothing references a WORM table. Every table has help (including how its rows get created) from `help/qbit-qa-capture.yaml`. Secure fields: a field is secure when core metadata marks it (`QFieldMetaData.isSensitive`, or a field-level data classification of PII or PHI through core's `AiFieldMetaData`, which `qbit-secured-data` classifications also set). There is no dependency on `qbit-secured-data`. Startup adds one `BODY_FIELD` rule per secure field name, insert-only: a rule an administrator edits or deactivates is never changed back. ## Permissions | Permission | Grants | |---|---| | `qa_capture.manage` | Read and write settings, audience and sanitizer rules (and reports). | | `qa_capture.view_reports` | Read and triage QA reports; the capability's `reportsHref`. | | `qa_capture.use_qa_mode` | The "QA capture" preference section with the QA mode switch (self-serve modes). | They are separately grantable. Capture itself is decided per user by the settings mode and audience; the capability never answers 403 (it returns `enabled: false` and a reason). `ROLE` audience rows match the names the config's role resolver returns, case-insensitively. QQQ 4.1 sessions have no role list of their own, so the default resolver returns the session's permission names; hosts pass their identity-provider groups (for example Authentik groups such as `kof22-admins`). `USER` rows match the session user's id reference or email, case-insensitively. ## Configuration | `QaCaptureQBitConfig` | Default | Meaning | |---|---|---| | `backendName` | required | Backend of the four tables. | | `environment` | required | Selects the `qa_capture_settings` row (for example `prod`). | | `backendVersion` | none | Reported in the capability context and on reports. | | `node` | `HOSTNAME` env | Reported in the capability context and on reports. | | `appName`, `appLabel` | `qaCapture`, `QA Capture` | The QBit's app (its four tables, no processes). | | `parentAppName` | none | Nest the app under the host's Admin app (must exist when producing). | | `withJamWebhookSecret(...)` / `withWebhookSecret(provider, ...)` | none | Webhook signing secret; without one the webhook answers 404. Never stored in tables or logged. | | `withRoleResolver(...)` | permission names | Role (group) names for `ROLE` audience rows. | | `withProvider(...)` / `withProviders(...)` | Jam | The provider registry (`QaCaptureProvider` SPI). | | `registerRoutes` | `true` | The UI API extension and the route provider. | | `syncSecuredDataRules` | `true` | Add `SECURED_DATA` rules at startup. | Environment variables are read by the host, not the QBit. Suggested names: `QA_CAPTURE_JAM_WEBHOOK_SECRET` (the `whsec_...` secret) and the host's existing environment and build variables. ## Routes | Route | Auth | What | |---|---|---| | `GET /qqq/v1/qbit/qa-capture/capability` (`qaCaptureCapability`) | session | The user's capability (contract). | | `POST /qqq/v1/qbit/qa-capture/report` (`qaCaptureReportCreate`) | session | Create a report; 403 when capture is off for the user. | | `GET /qa/recorder` | public | Jam's connected-domain page (team meta, both scripts, sanitizers from the active rules), its own CSP with the inline script's sha256, `Cache-Control: no-store`, never a redirect; 404 when capture is off. A matched route, so it wins over the Next dashboard fallback. | | `POST /qqq/hooks/qa-capture/jam` | Svix signature | `jam.created`: attaches the capture to the report named by `recordingLink.reference` (status `CAPTURED`), else creates a report. Idempotent on `jamId`; 1 MB cap; 5-minute timestamp tolerance. | | every `/qqq/*` and `/qa/*` response | | `X-Request-Id` (a valid incoming `[A-Za-z0-9._:-]{8,128}` value is reused, else a UUID). | ## Adopting in another app 1. Add the dependency, include the changelog, produce the QBit (above). 2. Add the provider origins to the Next dashboard CSP while capture is active: ```java new QApplicationJavalinServer(...) .withNextDashboardSecurityHeadersCustomizer(headers -> { myOtherCustomizations(headers); QaCaptureCsp.customize(headers); // or QaCaptureCsp.customize(headers, qInstance) }) ``` `QaCaptureCsp.customize(NextDashboardSecurityHeaders headers, QInstance instance)` adds `script-src https://js.jam.dev` and `frame-src`/`connect-src https://*.jam.dev` only when the environment's settings row has a mode other than `OFF` and a team id; `'self'` is untouched. It reads the database in the system context and never throws (a failure is logged and the headers stay as they were). The dashboard rebuilds its headers when the QInstance is set, so settings changes reach the dashboard CSP on the next restart or instance hot swap. The capability, the recorder page and the webhook follow the settings immediately. 3. Put the request id in your log pattern: `%X{requestId}` (log4j ThreadContext key `requestId`). Code can read it with `QaCaptureRequestIds.current()`. 4. Grant the permissions, add a settings row for each environment and choose the audience. 5. Optional: define core's key-value preference store (`UserPreferencesKeyValueStoreMetaDataProvider`); the QA mode switch then lives there instead of in `qa_capture_audience`. ## Jam setup 1. In Jam, connect your domain: Jam verifies a public URL that must not redirect, must keep query parameters and must contain both scripts and the matching `jam:team` meta. Use `https:///qa/recorder` (it answers 404 until a settings row turns capture on). 2. Copy the Jam team id (for example `0815d765-0953-4f79-91a3-f5ed435abbdd`) into `qa_capture_settings.team_id` for the environment and choose the mode. 3. Create a reusable Recording Link for the connected domain and put its id in `recording_link_id`; Report a bug opens it with `jam-recording`, `jam-title` and `jam-reference`. 4. Add a webhook for `jam.created` pointing to `https:///qqq/hooks/qa-capture/jam`; pass its signing secret (`whsec_...`) to `withJamWebhookSecret` from your environment. 5. Connect Jam's GitHub integration to file issues from captures; paste the issue URL into the report's GitHub Issue field (status `FILED`) while triaging. ## Build ```bash ./mvnw verify ``` The foreign key guardrail runs PostgreSQL in Docker (Testcontainers) and is skipped without Docker. The JavaScript sanitizer test runs in Node and is skipped without `node`. To regenerate the UI API fragment after a deliberate contract change: `./mvnw -Dtest=QaCaptureApiContractTest -Dqacapture.openapi.update=true test`. ## License Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).