# Diagnostics

> The sampled status report, field by field, and how to turn it off.

Source: https://whiskerjs.com/docs/diagnostics/ (last updated 2026-10-26)

> **In plain words:**
>
> By default, about 1 in 100 page loads that use WhiskerJS send one small report to the WhiskerJS project saying which components worked in that browser and which did not. It helps find signals that break in a new browser release. It never contains what was measured: no values, no digests, no ids. Any one of four switches turns it off. The server that receives it keeps only daily counts and never stores IP addresses or user agents.

## Turn it off

Any one is enough:

```ts
// 1. For the agent: no report from any collect() of this agent.
const agent = await load({ diagnostics: false });

// 2. For one call.
await agent.collect({ diagnostics: false });
```

```html
<!-- 3. For the whole page, in the HTML (content is case-insensitive). -->
<meta name="whiskerjs-diagnostics" content="off">
```

1. The visitor’s browser: `navigator.globalPrivacyControl === true` (Global Privacy Control), or Do Not Track set (`navigator.doNotTrack`, `window.doNotTrack` or `navigator.msDoNotTrack` is `'1'`, `'yes'` or `1`). If these cannot be read, diagnostics are treated as off.

With diagnostics off, the library makes no network request at all. The [live demo](https://whiskerjs.com/demo/) turns diagnostics off.

Other settings:

| Option | Default | Meaning |
| --- | --- | --- |
| `load({ diagnostics })` | `true` | `false` turns it off for the agent |
| `load({ diagnosticsEndpoint })` | `DIAGNOSTICS_ENDPOINT`, `https://diagnostics.whiskerjs.com/v1/report` | send to your own endpoint instead; must start with `https://`, anything else turns it off |
| `load({ diagnosticsSampleRate })` | `DEFAULT_DIAGNOSTICS_SAMPLE_RATE`, `0.01` | share of page loads that send, clamped to 0 (never) to 1 (every page load, still at most once) |
| `collect({ diagnostics })` | sends when sampled | `false` sends nothing for that call |

## The report

The whole body, field by field. Nothing else is sent:

| Field | Example | Meaning |
| --- | --- | --- |
| `version` | `"0.1.0"` | library version |
| `signalsVersion` | `"11"` | signal set version |
| `idVersion` | `"2"` | id derivation version |
| `browser_class` | `"chromium"` | the value of the `browser_class` component (`other` when the component is not `ok`) |
| `durationMs` | `412` | how long the collection took, whole milliseconds, at most 60000 |
| `components` | `{"audio":{"status":"ok"},"canvas":{"status":"blocked","ruleId":"threw"}}` | every component, sorted: only its `status` and, when it has one, its `ruleId` |

This is exactly the receiver’s schema: it answers 400 to any other field, a missing field, a value outside these shapes or a body over 8 KB. A result that would not fit (for example a version string the schema does not allow) is not sent at all.

`diagnosticsReport(result)` (exported) returns exactly this object for a given result, or `null` when nothing would be sent for it, so you can see what would be sent.

Never sent: component values or digests, the visitorId, the structuredId, integrity flags, the binding nonce, timestamp or digest, the page URL, the referrer, cookies, storage, or anything else from the page. The result itself is never sent by the library; your site sends it with its own request (see [Integration](https://whiskerjs.com/docs/integration/#sending-the-result)).

## How it is sent

- **When**: after `collect()` has its result and after your `onResult` hook. It is never awaited, never retried, and never delays or changes `collect()`; any failure is ignored.
- **How often**: the first `collect()` on a page makes the sampling decision once (for the window object, in memory; nothing is stored). Later calls on the same page, from any agent, never send. A call with `diagnostics: false`, an opt-out or an invalid endpoint makes no decision.
- **Sampling**: the window’s `Math.random()` against the sample rate.
- **Request**: one `fetch` with `method: 'POST'`, `mode: 'no-cors'`, `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` (no URL or referrer), `cache: 'no-store'`, `keepalive: true` and a `text/plain;charset=UTF-8` body (so no CORS preflight). `navigator.sendBeacon` is not used because it always sends cookies.

## What the browser adds on its own

Like every request a browser makes, the report request carries data the library cannot remove:

- the visitor’s **IP address** (the server needs it to answer) and the browser’s own request headers, including the **`User-Agent`** and the **`Origin`** (the page’s scheme and host, without the path);
- when the endpoint is down or unreachable (an outage, a blocker, a network error), the browser itself may print **one line** about the failed request in the developer console and show it in the network log. The library never writes to the console; this line comes from the browser;
- a page Content Security Policy without the endpoint in `connect-src` blocks the request and may report the violation to the page’s own reporting endpoint. A site that keeps diagnostics on adds `https://diagnostics.whiskerjs.com` to `connect-src`; with diagnostics off nothing beyond `script-src` is needed.

## What the server keeps

The receiver at `diagnostics.whiskerjs.com` is run by the WhiskerJS project. It **never stores the IP address or the user agent**, nor any other header, URL or referrer, and it does not keep the report body. It only adds the report to daily counts (per version, browser class, component, status and ruleId, and a histogram of `durationMs`), kept 400 days. The IP address is used only in memory, as the key of a rate limiter, through a salted hash whose salt is replaced every day; there are no access logs.

If your privacy notice does not cover this, turn diagnostics off. See [Privacy](https://whiskerjs.com/docs/privacy/) and [what a privacy notice should say](https://whiskerjs.com/privacy/privacy-notice/).

## How the tests keep it honest

- The compiled-source scan allows a network API only in `src/diagnostics.ts` and checks that file sends no credentials and no referrer.
- The fake browser pins `Math.random` outside the sample, so every other test stays deterministic and sends nothing; the one test that runs against the real Node globals passes `diagnostics: false`.
- Dedicated tests cover the exact payload, the unsampled case, once per page load across agents, every off switch, a sample rate of 0, a non-https endpoint, a custom endpoint and rate, and failing, hanging and throwing sends.
- A schema test keeps a copy of the receiver’s rules and checks that every browser class, status, component name and ruleId the library can emit is accepted, that a report for each browser class and a worst case (every component with a 32-character ruleId) fit, and that a result outside the schema sends nothing.
- The real-browser tests (Chromium, Firefox in three settings, WebKit) run twice in CI: with diagnostics on and sampling forced to every page load, the only request allowed beyond the page’s own files is exactly one diagnostics report per page load, checked against the receiver’s schema (the test answers it; it never reaches the real endpoint); with diagnostics off, the job asserts zero requests beyond the page’s own files.
