invisit
All posts

By Miraç Berk12 min read

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.

A soft 3D open book with a search bar and a small chat bubble rising from its pages, in violet dithered light and shadows.

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:

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). 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, search, feedback, integrations, custom scripts, pricing), GitBook (traffic, plans, docs analytics, integrations), ReadMe (metrics, custom code, plans, integrations), Docusaurus plugins and search, Starlight, VitePress search, Nextra (search, layout), Material for MkDocs.

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). GitBook’s own analytics sets cookies to recognise returning visitors, and turning them off turns its analytics off (site settings). Material for MkDocs is in maintenance mode, with critical fixes until May 5, 2027 (announcement).

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). 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:

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 covers the rest of the Starlight setup:

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:

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:

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.

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:

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 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). 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.
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 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 does that for any origin:

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.

Whether one visit can span two subdomains depends on the tool; cross-domain tracking covers it.

Checklist for measuring your docs