[invisit](https://invisit.dev) · [Docs](https://invisit.dev/docs) · [llms.txt](https://invisit.dev/llms.txt) · [llms-full.txt](https://invisit.dev/llms-full.txt)

# Documentation analytics: what to measure on developer docs and how

> What to measure on docs besides page views, what Mintlify, GitBook, ReadMe, Docusaurus, Starlight and VitePress report, and tested code for search events.

- Author: Miraç Berk
- Published: 2026-11-03
- Page: https://invisit.dev/blog/documentation-analytics
- Tags: documentation, events, ai-traffic, site-search

![A soft 3D open book with a search bar and a small chat bubble rising from its pages, in violet dithered light and shadows.](https://invisit.dev/_astro/cover.DGWnop9Q.png)

Documentation analytics measures whether your docs answer people, not only how many open them: what readers search for and fail to find, which pages search engines and AI assistants send them to, whether a page helped, which code they copy, and whether the docs lead to a signup or the dashboard. It also counts the AI crawlers and assistants that read your docs without running JavaScript, which page-view analytics never sees.

As of November 2026, Mintlify, GitBook and ReadMe report searches, page ratings and AI agent reads on their own. Docusaurus, Starlight, VitePress and Nextra report nothing, so you add a tracker and a few events. Below is a table of what each platform gives you, then search, rating and copy-code events we tested on a Starlight 0.42.8 site with invisit, the cookieless analytics tool we build, and how to see which pages AI assistants read.

## Why do page views mislead on documentation?

A page view counts the reader who found the answer in ten seconds and the reader who gave up and opened a support ticket the same way. Docs traffic also rises for bad reasons: a confusing API or a breaking release sends people to the troubleshooting page, which then tops your report.

Three distortions are specific to docs:

- Search hides failure. A reader who searches, finds nothing and leaves records one page view on the page they started from.
- Agents read without a browser. A coding assistant that fetches `/llms.txt` or a page's Markdown copy runs no JavaScript, so a script-based tool records nothing. GitBook counts these readers in a separate report and Mintlify in a separate agent view, so their human numbers stay clean.
- Measurement changes move the line. Mintlify says agent views rose on most sites around June 8, 2026, because it began recognising more agent user agents, which reflects "improved measurement and agent access, not a sudden change in traffic" ([Mintlify traffic docs](https://www.mintlify.com/docs/optimize/analytics/traffic)).

## What to measure on a documentation site

Seven signals tell you more about docs than page views do. Each comes from a different place, and the last one never comes from the browser:

| Signal                                 | What it tells you                                              | Where it comes from                                                     |
| -------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Searches, and searches with no results | The words readers use, and the topics you never covered        | Your search tool, or a custom event                                     |
| Entry pages from search engines        | Which pages rank                                               | Referrer plus entry page; the queries themselves only in Search Console |
| Entry pages from AI assistants         | Which pages ChatGPT, Claude or Perplexity send people to       | AI referrers and `utm_source`                                           |
| "Was this page helpful?"               | Which pages fail readers                                       | A rating widget                                                         |
| Copy-code clicks                       | Which examples people actually run                             | A click on the copy button                                              |
| Docs to signup or dashboard            | Whether the docs bring users                                   | A click event, or a funnel                                              |
| AI crawler and assistant reads         | Which pages, `llms.txt` files and Markdown copies agents fetch | Server or edge logs                                                     |

GitBook's own guide calls search terms possibly "the most valuable quick-win metric available" ([GitBook](https://gitbook.com/docs/guides/docs-analytics/documentation-analytics)). A search with no results is a reader naming, in their words, what is missing or misnamed.

## Which docs platforms have built-in analytics?

Mintlify, GitBook and ReadMe do, each with search reports and page feedback. Material for MkDocs integrates Google Analytics 4 and a feedback widget. Docusaurus, Starlight, VitePress and Nextra have none and run whatever script you add. As of November 2026, from each vendor's docs:

| Platform                | Built-in analytics                                                                                              | Your own script                                                                                          | Search analytics                                                 | Page feedback                                                      |
| ----------------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------ |
| Mintlify                | Pro ($450 a month billed yearly) and Enterprise: traffic, human and agent views, search, feedback               | Any `.js` file in the content folder, every plan; 16 integrations, GA4, Plausible and PostHog among them | Volume, frequent queries, queries with no results, click-through | Thumbs, comments, code snippet feedback, agent feedback            |
| GitBook                 | Essential ($65 per site a month billed yearly) and up: Traffic, Agents & MCP, AI Assistant                      | No custom JavaScript; integrations only, such as GA, Plausible, Fathom, PostHog                          | What readers typed; searches with no results among its gaps      | Sad, neutral or happy face, with comments                          |
| ReadMe                  | Metrics on every plan, Starter ($0) included: page views split into agent and human, search terms, page quality | Custom JavaScript on Enterprise only; GA and Segment integrations                                        | Top search terms and volume                                      | "Did this page help you?" thumbs, with a comment                   |
| Docusaurus 3.10         | None                                                                                                            | `scripts` config or a client module; official gtag and Tag Manager plugins                               | With Algolia DocSearch, in Algolia's dashboard                   | None                                                               |
| Starlight 0.42          | None                                                                                                            | `head` config                                                                                            | None: Pagefind searches in the browser                           | None                                                               |
| VitePress 1.6           | None                                                                                                            | `head` config                                                                                            | None for local search (MiniSearch); Algolia as with Docusaurus   | None                                                               |
| Nextra 4.6              | None                                                                                                            | Anything: it is a Next.js app                                                                            | None: Pagefind                                                   | A "Question? Give us feedback" link that opens a GitHub issue      |
| Material for MkDocs 9.8 | GA4 integration                                                                                                 | A custom analytics partial                                                                               | Through GA4's site search measurement                            | Built-in "Was this page helpful?" widget, sent to GA4 or your code |

Sources: Mintlify ([analytics](https://www.mintlify.com/docs/optimize/analytics), [search](https://www.mintlify.com/docs/optimize/analytics/search), [feedback](https://www.mintlify.com/docs/optimize/feedback), [integrations](https://www.mintlify.com/docs/integrations/analytics/overview), [custom scripts](https://www.mintlify.com/docs/customize/custom-scripts), [pricing](https://www.mintlify.com/pricing)), GitBook ([traffic](https://gitbook.com/docs/analytics/insights/traffic), [plans](https://gitbook.com/docs/account-and-billing/plans), [docs analytics](https://www.gitbook.com/features/docs-analytics), [integrations](https://gitbook.com/docs/help-center/published-documentation/site-insights/how-can-i-access-insights-or-analytics)), ReadMe ([metrics](https://docs.readme.com/main/docs/using-metrics-charts), [custom code](https://docs.readme.com/main/docs/custom-css-and-javascript), [plans](https://docs.readme.com/main/docs/plans-and-pricing), [integrations](https://docs.readme.com/main/docs/integrations)), [Docusaurus plugins](https://docusaurus.io/docs/api/plugins) and [search](https://docusaurus.io/docs/search), [Starlight](https://starlight.astro.build/reference/configuration/#head), [VitePress search](https://vitepress.dev/reference/default-theme-search), Nextra ([search](https://nextra.site/docs/guide/search), [layout](https://nextra.site/docs/docs-theme/built-ins/layout)), [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/setup/setting-up-site-analytics/).

Three limits matter when you choose. GitBook runs no third-party script outside its integration list, invisit included: building an integration that injects JavaScript "is not possible at this time" ([GitBook](https://gitbook.com/docs/developers/integrations/configurations)). GitBook's own analytics sets cookies to recognise returning visitors, and turning them off turns its analytics off ([site settings](https://gitbook.com/docs/manage-your-site/site-settings)). Material for MkDocs is in maintenance mode, with critical fixes until May 5, 2027 ([announcement](https://github.com/squidfunk/mkdocs-material/issues/8523)).

On Docusaurus with DocSearch you need no search code. Algolia's dashboard has a Searches without Results tab, counts as-you-type searches within 30 seconds as one, and tells users apart by IP address unless you set a user token ([Algolia's metrics](https://www.algolia.com/doc/guides/search-analytics/concepts/metrics)). Its searches without clicks need click events, which Docusaurus sends only with `insights: true`, off by default.

## How to send docs search queries as events without personal data

Send one event per finished search, with the cleaned-up query, the number of results and a `found` flag, and drop every query that looks like an email address, a phone number, a key or a file path. Starlight, Nextra and any other Pagefind site search in the browser, so nothing reaches a server unless you send it. This module listens to Pagefind's default search box and counts results with the same Pagefind file the box loaded:

```js title="public/docs-search.js"
// One docs_search event per finished search: never the keystrokes on the way, and never a query
// that looks like someone's data rather than a docs topic.
const PAGEFIND = '/pagefind/pagefind.js' // the same file the search box loads; add your base path
const MEASURE_MS = 500 // wait this long after the last keystroke, then count the results
const SETTLE_MS = 1000 // and this much longer before sending, unless the reader leaves first

const EMAIL = /[^\s@]+@[^\s@]+\.[^\s@]+/
const LONG_NUMBER = /\d{9,}/ // phone, card and order numbers, once spaces and dashes are gone
const KEY_LIKE = /(?=[\w-]*\d)(?=[\w-]*[a-z])[\w-]{24,}/i // API keys, tokens, UUIDs
const HOME_PATH = /\/users\/|\/home\/|[a-z]:\\users\\/i // pasted stack traces name the user

function clean(raw) {
  const query = raw.normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase().slice(0, 80)
  if (query.length < 2) return null
  const digits = query.replace(/[\s().+-]/g, '')
  if (
    EMAIL.test(query) ||
    LONG_NUMBER.test(digits) ||
    KEY_LIKE.test(query) ||
    HOME_PATH.test(query)
  ) {
    return null
  }
  return query
}

let latest = null
let measureTimer
let sendTimer

function send() {
  clearTimeout(sendTimer)
  if (!latest) return
  window.invisit?.track('docs_search', latest)
  latest = null
}

document.addEventListener('input', (event) => {
  const input = event.target
  if (!(input instanceof HTMLInputElement) || !input.matches('.pagefind-ui__search-input')) return
  clearTimeout(measureTimer)
  clearTimeout(sendTimer)
  latest = null
  const query = clean(input.value)
  if (!query) return
  measureTimer = setTimeout(async () => {
    const pagefind = await import(PAGEFIND)
    const { results } = await pagefind.search(query)
    if (clean(input.value) !== query) return // the reader kept typing
    latest = { query, results: results.length, found: results.length > 0 }
    sendTimer = setTimeout(send, SETTLE_MS)
  }, MEASURE_MS)
})

// A reader who clicks a result leaves before the timer fires: send what they searched on the way out.
addEventListener('pagehide', () => {
  send()
  window.invisit?.flush()
})
```

In Starlight, load it next to the tracker through the `head` option. Files in `public/` are served as they are, and the [Astro analytics guide](/blog/astro-analytics#how-to-add-analytics-to-a-starlight-docs-site) covers the rest of the Starlight setup:

```js title="astro.config.mjs"
import { defineConfig } from 'astro/config'
import starlight from '@astrojs/starlight'

export default defineConfig({
  integrations: [
    starlight({
      title: 'Docs',
      head: [
        {
          tag: 'script',
          attrs: {
            src: 'https://api.invisit.dev/invisit.js',
            'data-site': '<site id>',
            'data-collector': 'https://api.invisit.dev',
            async: true,
          },
        },
        { tag: 'script', attrs: { type: 'module', src: '/docs-search.js' } },
      ],
    }),
  ],
})
```

Three choices in it are deliberate:

- The query is lowercased and its spaces collapsed, so "Webhook signature" and "webhook signature" land in one row.
- `found` is a boolean because analytics tools list booleans and strings as values you can click, while a number usually becomes an average.
- The `pagehide` branch exists because Starlight is a multi-page site: clicking a result loads a new page and kills the timer.

On Nextra, Pagefind lives at `/_pagefind/pagefind.js` and the search box is Nextra's own, so change `PAGEFIND` and the selector. For another tool, swap the `track` line for its call.

### What we tested

On November 3, 2026 we served a static Starlight 0.42.8 build (Astro 7.4.5, Pagefind 1.6.0), drove it with Playwright in headless Chrome 155, sent events to our local invisit development stack with `data-debug="true"` so automation is not ignored, and read the stored events back from the database:

| Typed into the search box                        | Stored event                                                   |
| ------------------------------------------------ | -------------------------------------------------------------- |
| `Webhook  signature`                             | `query: "webhook signature"`, `results: 1`, `found: true`      |
| `graphql subscriptions`                          | `query: "graphql subscriptions"`, `results: 0`, `found: false` |
| `jane.doe@example.com`                           | Nothing                                                        |
| `sk_live_51H8xYzAbCdEfGhIjKlMnOp`                | Nothing                                                        |
| `ENOENT /Users/jane/app/config.json`             | Nothing                                                        |
| `+1 (415) 555-0134`                              | Nothing                                                        |
| `@astrojs/starlight`                             | `results: 3`, `found: true`                                    |
| `api key`, then a result clicked within a second | `results: 2`, `found: true`, sent on `pagehide`                |

No partial query was ever sent. One trap if you test this way: a user agent set on Playwright's browser context did not cover requests sent while a page unloads. Those left as `HeadlessChrome`, and the collector, correctly, dropped them as a headless browser. Passing `--user-agent` when launching the browser fixed it.

To list searches with no results in invisit, open Events, pick `docs_search`, and click No under `found` in the Properties card; the `query` list then holds only the searches that found nothing. We checked that filter through the same API the dashboard uses. Each `docs_search` is a billed custom event, one more reason to send one per finished search. As a second net, invisit replaces email addresses and long digit runs in property values with `[redacted]`, and cuts strings at 256 characters.

## How to add "Was this page helpful?" to Starlight, Docusaurus or VitePress

Put two buttons at the bottom of each page that each send one event with the answer and the page path. In Starlight, override the `Footer` component; with invisit, `data-invisit-event` attributes record the click without any tracking code:

```astro title="src/components/Footer.astro"
---
import Default from '@astrojs/starlight/components/Footer.astro'

const page = Astro.url.pathname
---

<div class="page-rating">
  <span>Was this page helpful?</span>
  <button type="button" data-invisit-event="page_rated" data-invisit-prop-helpful="yes" data-invisit-prop-page={page}>Yes</button>
  <button type="button" data-invisit-event="page_rated" data-invisit-prop-helpful="no" data-invisit-prop-page={page}>No</button>
</div>
<Default><slot /></Default>

<script>
  // One answer per page view: thank the reader and take the buttons away.
  for (const button of document.querySelectorAll('.page-rating button')) {
    button.addEventListener('click', () => {
      button.parentElement!.textContent = 'Thanks for telling us.'
    })
  }
</script>
```

Register it with `components: { Footer: './src/components/Footer.astro' }` in the Starlight config. The page travels as a property because invisit breaks an event down by its properties, not by page: click `no` under `helpful` and the `page` list shows the pages that failed most often. In our test, three clicks on two pages stored three `page_rated` events with both properties.

The same buttons go into Docusaurus by wrapping the `DocItem/Footer` component with swizzle, and into VitePress through the default theme's `doc-after` layout slot. Material for MkDocs already draws the widget; set `provider: custom` and call `window.invisit?.track()` from the submit handler in its [custom feedback example](https://squidfunk.github.io/mkdocs-material/setup/setting-up-site-analytics/#custom-site-feedback).

### invisit's feedback widget answers the why

invisit's on-page feedback widget is a free-text form, not a rating: a message of up to 2,000 characters and an optional email address for a reply. Each message is stored with its page, with sensitive query parameters stripped, the country, device type, browser and operating system, and a link to the visit when the tracker may follow the visitor.

Messages land in the site's Feedbacks inbox, marked new or resolved and searchable, each with the visit that led to it, page by page. Turn it on under Settings → Collection and choose the button text, corner, question and accent colour, or hide the floating button and open it from your own link with `invisit.feedback()`. Feedback is never billed. The rating finds the pages; the widget tells you what is wrong with them.

## How to track copy-code clicks in docs

Listen for clicks on the code blocks' copy buttons and send the page and the code's language. Each generator draws its own button, so one delegated listener covers three of them:

```js title="public/code-copy.js"
// One code_copied event per click on a code block's copy button, with the page and the language.
const BUTTONS = [
  '.expressive-code .copy button', // Starlight
  'button[aria-label="Copy code to clipboard"]', // Docusaurus, English UI
  'div[class*="language-"] > button.copy', // VitePress
].join(', ')

document.addEventListener('click', (event) => {
  const button = event.target instanceof Element ? event.target.closest(BUTTONS) : null
  if (!button) return
  const block = button.closest('.expressive-code, div[class*="language-"]')
  const lang =
    block?.querySelector('pre[data-language]')?.dataset.language ??
    block?.className.match(/language-([\w-]+)/)?.[1] ??
    'text'
  window.invisit?.track('code_copied', { page: location.pathname, lang })
})
```

We clicked both copy buttons on a Starlight page and got two events, `lang: "sh"` and `lang: "ts"`. The Docusaurus and VitePress selectors come from the source of Docusaurus 3.10.2 and VitePress 1.6.4, and Docusaurus translates its button label on sites in other languages. A reader who selects code and presses Ctrl+C never touches the button, so treat the count as a floor. Mintlify records copies on its own as `docs.code_block.copy`.

## Which docs pages do AI assistants read?

Two different things answer that question: people who click through from an AI answer, which a browser tracker sees, and agents that fetch your pages themselves, which only the server sees.

For clicks, invisit's AI tab in the Sources card recognises 19 assistants by referrer and by `utm_source`. Click ChatGPT there and the dashboard filters to its referrer domains; the Entry tab of the Pages card then lists the docs pages those visitors landed on. A Google row does the same for search, though the queries themselves stay in Google Search Console, which invisit does not import. [Our AI traffic guide](/blog/ai-assistant-traffic) compares how other tools file these clicks.

For reads, the hosted platforms report it for you. Mintlify groups agent views by provider, such as ChatGPT, Claude and Cursor, with a bucket for HTTP clients like cURL and Python. GitBook's Agents & MCP report counts reads by surface: Markdown, `llms.txt`, MCP, page views and RSS ([GitBook](https://gitbook.com/docs/analytics/insights/agents-and-mcp)). ReadMe splits page views into agent and human.

On a docs site you run yourself, invisit records these reads with `@invisitdev/server` in your middleware. It keeps GET and HEAD document requests whose user agent looks like a crawler, and skips framework data requests and static assets by extension (CSS, JavaScript, images, fonts). So `/llms.txt`, `/llms-full.txt`, every `.md` copy, `robots.txt` and sitemaps show up by path. The Crawlers card groups them into AI training, AI search and AI assistants, the user-triggered fetchers such as ChatGPT-User and Claude-User. Click a bot to see the pages it read. Crawler reads are never billed.

![invisit's Crawlers card with the AI tab open, listing GPTBot, ClaudeBot, OAI-SearchBot, Meta-ExternalAgent, ChatGPT-User, Amazonbot, PerplexityBot and Bytespider with their reads and shares.](https://invisit.dev/_astro/crawlers-ai.DsFB_7P7.png)

*AI crawler reads by bot over 30 days, with sample data from the live demo.*

Mintlify counts something invisit does not. invisit records a user agent only when it names a known crawler or calls itself a bot, crawler, spider or fetcher, so a coding agent that fetches your docs as plain curl or python-requests goes uncounted. [Our AI crawler list](/blog/ai-crawlers) names every documented AI user agent and shows how to count them in an nginx log.

### What does a static docs host mean for crawler reads?

On a static host such as GitHub Pages, S3 or a plain file server, no code of yours runs per request, so a browser script sees only humans. Read the host's access logs, or put an edge function in front. invisit's [Cloudflare Workers setup](https://invisit.dev/docs/server/frameworks.md#cloudflare-workers) does that for any origin:

```ts title="src/worker.ts"
import { createInvisit } from '@invisitdev/server'

export default {
  async fetch(request, env, ctx) {
    const invisit = createInvisit({ key: env.INVISIT_API_KEY })
    const proxied = await invisit.proxy(request)
    if (proxied) return proxied
    const response = await fetch(request) // your origin, or your app's own handler
    ctx.waitUntil(invisit.crawlers(request, { status: response.status }))
    return response
  },
} satisfies ExportedHandler<{ INVISIT_API_KEY: string }>
```

invisit.dev's own docs are the static case: an Astro site built to files and served by Caddy. Agents get an `llms.txt` listing all 15 docs pages, an `llms-full.txt` of about 83 KB with every page in one file, a `.md` copy of each page, Markdown at the page's own address for requests that send `Accept: text/markdown`, a `Link` header pointing to that copy, and a Copy as Markdown button. Each of those reads is a file served with no script attached, so counting them takes the server or the edge.

## How do you know whether docs lead to signups?

Measure the click from the docs to your signup or dashboard, and where the docs share a host with your signup page, build a funnel.

- Docs on `docs.example.com` and the app on `app.example.com`: invisit records each click into the app as an `outbound_link` event with the link's address, because the origin differs. They appear in the Outbound tab of the Pages card. [Our outbound link guide](/blog/outbound-link-tracking) covers what counts.
- Docs under `example.com/docs`: a funnel from a docs page to `/signup` to your `signup_completed` event shows how many visits made it, within one visit and 2 to 8 steps. [Our funnel guide](/blog/funnel-analysis) explains how tools count those steps.
- A call to action inside the docs, such as Get an API key: add `data-invisit-event="docs_cta_clicked"` to the link.

Whether one visit can span two subdomains depends on the tool; [cross-domain tracking](/blog/cross-domain-tracking) covers it.

## Checklist for measuring your docs

- [ ] Check the table first: on Mintlify, GitBook or ReadMe, searches and ratings may already be recorded.
- [ ] Send one search event per finished search, with a `found` flag, and drop queries that look like personal data.
- [ ] Put the page path into every rating and copy event as a property.
- [ ] Keep human page views and agent reads apart, and read agent numbers from the server or the edge.
- [ ] Record reads of `llms.txt` and `.md` copies where the requests arrive.
- [ ] Track the clicks from docs into your app or signup page.

> [!TIP]
> Start with searches that found nothing. After a week, take the five most frequent, and for each one add the reader's word to the page that answers it, or write the page that is missing.
