# Real User Monitoring

> Turn on Core Web Vitals measurement without code changes, choose a sample rate, exclude paths, allow it in your CSP and read the report.

Real User Monitoring (RUM) measures [Core Web Vitals](https://developers.openwaf.dev/analytics/core-web-vitals/) in the browsers of your real visitors. You don't change your site: the edge adds a small script to your HTML pages, and the browser reports to your own domain. RUM is **off by default** and can be set per zone and per name.

## How it works

1. The edge adds `<script defer src="/__waf/rum.js?v=…">` before `</head>` of HTML pages. The script is about 5 KB compressed and served by the edge itself.
2. The browser measures LCP, INP, CLS, FCP and TTFB while the page is open.
3. When the page is hidden (tab switched, closed or navigated away), it sends one beacon to `/__waf/rum` on your domain. Later reports of the same view only carry what changed, usually INP and CLS.
4. The edge validates the beacon, adds the device class and country, discards the IP address and User-Agent, and forwards it. Data shows up in the report about a minute later.


The script is only added to `200` answers to `GET` requests with an HTML `Content-Type`, outside excluded paths, never to pages the edge generates (challenges, block pages, access gate) and according to the sample rate. It is added when the page is served, so it also reaches pages served from the [edge cache](https://developers.openwaf.dev/cache/), and cached copies never contain it. Compressed pages are decompressed, injected and compressed again.

## Turn it on

**Dashboard**

Go to **Analytics › Real performance**, choose a **Sample rate** and select **Enable**. Afterwards, the **Settings** of the page let you change the sample rate and the **Excluded paths**, or **Turn off** measurement.

**CLI**

```sh
openwaf-cloud ctl zones set example.com rum.enabled=true rum.sample_rate=0.25 rum.exclude_paths=/wp-admin/,/wp-login.php --reason "measure web vitals"
```

`rum.sample_rate` is read as a number and `rum.exclude_paths` as a comma-separated list (`rum.exclude_paths=` empties it).

**API**

```sh
curl -s -X PATCH https://dash.openwaf.dev/v1/orgs/$ORG/zones/$ZONE/edge \
  -H "Authorization: Bearer $OPENWAF_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Change-Reason: measure web vitals" \
  -d '{"rum": {"enabled": true, "sample_rate": 0.25, "exclude_paths": ["/wp-admin/"]}}'
```

**MCP**

`update_settings` with `{"zone": "example.com", "settings": {"rum": {"enabled": true}}, "reason": "measure web vitals"}`, first `confirm=false`, then `confirm=true` with `expected_version`.


Like every edge setting, each change creates a zone version, is recorded in the audit log and can be reverted.

## Settings

| Key | Values | Default |
|---|---|---|
| `rum.enabled` | `true`, `false` | `false` |
| `rum.sample_rate` | More than `0` and at most `1`: the share of page views that get the script (`0.25` = one in four). | `1` |
| `rum.exclude_paths` | Path prefixes that never get the script. | `[]` |

Values are validated even while RUM is off: a `sample_rate` of `0` or above `1`, a relative path or an unknown field is rejected with `422`.

### Excluded paths

Up to 50 prefixes. Each one starts with `/`, is at most 256 bytes, has no `?` or `#` and appears only once; spaces are trimmed and empty lines dropped. Exclude pages you don't want measured, for example `/admin` or `/checkout`.

### Per name

Each field set for a name replaces the zone's for that name:

```sh
openwaf-cloud ctl zones set example.com --host shop.example.com rum.sample_rate=1 --reason "measure the whole shop"
openwaf-cloud ctl zones set example.com --host admin.example.com rum.enabled=false --reason "no RUM on the admin"
openwaf-cloud ctl zones set example.com --host admin.example.com --unset rum --reason "inherit the zone"
```

In the API: `{"hosts": {"admin.example.com": {"rum": {"enabled": false}}}}`; `{"rum": null}` inside the name inherits the zone again.

## Content-Security-Policy

If your origin sends a `Content-Security-Policy`, it must let the script load and report to your own origin:

- allow `'self'` in `script-src` and `connect-src`, **or**
- send a `nonce` in `script-src`: the edge reuses it for its script.

When the policy would block the script, the edge doesn't inject it, and the report warns how many page views weren't measured because of the CSP.

## Read the report

**Dashboard**

**Analytics › Real performance** is laid out like the field data of PageSpeed Insights:

- device tabs **Mobile** (default), **Desktop**, **Tablet** (only when there are visits) and **All**;
- **This page** or **Whole site**;
- the **Core Web Vitals Assessment** and one tile per metric with its p75 and the share of good, needs-improvement and poor views (**Expand view** adds thresholds, trend and sample size);
- **Diagnose performance issues**: LCP breakdown, LCP element, interactions and scripts behind INP, layout shifts, and breakdowns by page, country, edge node, connection and over time.

Filters (period 24 h, 7 or 30 days; country; edge node; page) live in the address, so you can share a view.

**CLI**

```sh
openwaf-cloud ctl rum report example.com                                  # last 7 days
openwaf-cloud ctl rum report example.com --since 24h --device mobile --country ES
openwaf-cloud ctl rum report example.com --since 30d --by country --json
```

Options: `--since`/`--until` (a duration such as `24h` or `7d`, or RFC 3339), `--host`, `--path`, `--country`, `--node`, `--device`, `--connection`, `--nav-type` and `--by path|country|node|device|connection`. It prints the p75 of each metric with its rating, the distribution, the comparison with the previous period, the LCP breakdown, the chosen breakdown and the worst elements; `--json` prints the API answer.

**API**

```sh
curl -s "https://dash.openwaf.dev/v1/orgs/$ORG/zones/$ZONE/rum/report?since=2026-10-20T00:00:00Z&device=mobile" \
  -H "Authorization: Bearer $OPENWAF_TOKEN"
```

See the [RUM API reference](https://developers.openwaf.dev/analytics/rum-api/) for every route and parameter.

**MCP**

`rum_report` is read-only. Ask, for example, "How is LCP on mobile in Spain this week?"; the assistant calls:

```json
{"zone": "example.com", "period": "7d", "device": "mobile", "country": "ES"}
```

`period` is `24h`, `7d` (default) or `30d`; filters `device`, `country`, `node`, `path`, `host`, `connection`, `nav_type` and `by` work as in the API.


## Related

- [RUM privacy](https://developers.openwaf.dev/analytics/rum-privacy/)
- [Troubleshooting](https://developers.openwaf.dev/analytics/troubleshooting/#no-rum-data)
