duxt reports what readers do with a documentation site — searching, copying a
command, trying an endpoint, answering "Was this page helpful?" — to **one
function you write**. It ships no provider, no SDK and no script, and a site
that has not written that function reports nothing and downloads nothing extra.

::callout{type="info" title="Off until you turn it on"}
There is no default destination, because a documentation layer cannot pick one
for somebody else's readers. Without `duxt.analytics.track`, every call site
below reaches a missing function and stops.
::

## Steps

::steps

1. **Write the callback.** It lives in `app.config.ts`, beside the rest of your
   duxt config, and it is handed one event per interaction:

   ```ts [app/app.config.ts]
   export default defineAppConfig({
     duxt: {
       analytics: {
         track(event) {
           // `event.name` is the discriminant; `event.context` says which
           // page, locale, source and version it happened on.
           console.log(event.name, event.context.path);
         }
       }
     }
   });
   ```

   The callback runs **in the browser only**. A build and a prerender call it
   never, so a static render does not fill your analytics with pages a crawler
   opened.

2. **Send it where your analytics already live.** Whatever your provider
   exposes — an SDK already on the page, or a route of your own so the reader's
   browser never talks to a third party you did not choose:

   ```ts [app/app.config.ts]
   export default defineAppConfig({
     duxt: {
       analytics: {
         track(event) {
           const { name, context, ...properties } = event;

           // Your provider, your property names, your consent gate.
           window.plausible?.(name, { props: { ...context, ...properties } });
         }
       }
     }
   });
   ```

   ::callout{type="warning" title="Consent is yours, not duxt's"}
   duxt sets no cookie, stores nothing, buffers nothing and shows no consent
   banner — it has nothing to ask about, because nothing leaves the page except
   through your function. That also means **the gate is yours**: if your privacy
   policy requires consent before this reaches a provider, check it inside
   `track` and return early.
   ::

3. **Pick the events you want, and drop the rest.** The union is closed, so a
   `switch` covers it exhaustively and anything you do not name is simply not
   sent:

   ```ts [app/app.config.ts]
   track(event) {
     if (event.name === 'search' && event.results === 0) {
       // The one report a documentation site nearly always wants: a term
       // somebody looked for and the site could not answer.
       report('docs-search-miss', { query: event.query, ...event.context });
     }
   }
   ```

4. **Check one event arrives**, with the context you expected — under a version
   and locale prefix, not just at the bare URL.

::

## The events

Every event has a `name`, its own fields, and a `context`.

| `name` | Emitted when | Carries |
| :--- | :--- | :--- |
| `search` | A debounced, non-empty query settles — including one that found nothing | `query`, `results`, `approximate` |
| `search-result` | A reader opens a hit | `query`, `rank`, `to`, `collection` |
| `copy` | A layer-owned code example is copied | `kind`, `language`, `manager`, `sample` |
| `api-request` | A try-it request settles | `operation`, `method`, `path`, `outcome`, `statusClass`, `duration` |
| `feedback` | A reader answers "Was this page helpful?" | `helpful` |

`search` fires **once per settled query**, not per keystroke: the debounce drops
what nobody finished typing, and a result superseded by a newer one is never
reported, because the reader never saw it.

`copy` covers the layer's own code examples — a fence, a `::code-group`, a
`::package-managers` block, a request sample in the try-it client — with `kind`
saying which. **"Copy page as Markdown" is not in this family** and emits
nothing: it is the page, not an example in it.

`api-request` describes the operation **as the OpenAPI document declares it**.
`path` is the template (`/pets/{id}`), never the URL that was called.

### The context

| Field | Notes |
| :--- | :--- |
| `path` | The documentation path, without the locale prefix, query string or fragment |
| `locale` | The locale the page was read in |
| `source` | Which source the page came out of — absent on a single-source site |
| `version` | Which version — absent where the source has only one |

## What is never sent

This list is the point of the feature, so it is worth stating plainly. No event
carries:

- an API credential, header or token;
- a request or response body, or a concrete request URL;
- the text that was copied;
- a search result's rendered content;
- a browser, session or reader identifier of any kind;
- the query string or fragment off the page's own path.

duxt also never **transmits, buffers or persists** anything itself. There is no
queue, no retry, no storage and no network call in the layer: your function is
the only thing that ever leaves the page.

Your callback is never awaited, and whatever it throws or rejects with is
swallowed — a provider that is down must not take the search, the copy, the
feedback or the request down with it.

## Page feedback, either way

`feedback` is the same answer `DuxtPageFeedback` has always emitted. The
component's `@feedback` event and its default slot are unchanged, so
[wrapping it](/guides/collect-page-feedback) still works and neither route needs
the other — use the analytics callback if you want one destination for
everything, and the wrapper if feedback alone goes somewhere of its own.

## Checklist

- [ ] `duxt.analytics.track` is set, and nothing else was needed to turn it on
- [ ] The destination is yours — a provider you chose, or a route of your own
- [ ] Consent is checked inside `track`, if your policy asks for it
- [ ] An event arrived with the path, locale, source and version you expected
- [ ] Nothing in what you forward on identifies the reader