# @backendfree/webanalytics

> Reporting page views from your own server, for the visitors and crawlers a script never sees.

Report page views to BackendFree from your own server, for the visitors and
crawlers a browser snippet never sees.

The snippet on the Tracking screen covers people with JavaScript. This covers
everything else: crawlers, which mostly do not run scripts at all, and any
request you want measured before a page is rendered.

```sh
npm install @backendfree/webanalytics
```

## Using it

Report a page view where your server answers a page load. In Next.js that is
`proxy.ts` (`middleware.ts` before Next 16):

```ts
import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server';
import { Reporter } from '@backendfree/webanalytics';

const reporter = new Reporter({
  origin: 'https://backendfree.com',
  key: process.env.BACKENDFREE_SECRET_KEY!,
});

export function proxy(request: NextRequest, event: NextFetchEvent) {
  // A page load, or a crawler, which sends no Sec-Fetch-Dest at all. Not the
  // prefetches and data requests an app makes in the background.
  const dest = request.headers.get('sec-fetch-dest');
  if (request.method === 'GET' && (dest === null || dest === 'document')) {
    reporter.pageview({
      url: request.url,
      referrer: request.headers.get('referer') ?? '',
      ip: request.headers.get('cf-connecting-ip') ?? '',
      userAgent: request.headers.get('user-agent') ?? '',
      optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
    });
    event.waitUntil(reporter.flush());
  }
  return NextResponse.next();
}

export const config = {
  // Not the API routes, the framework's own files, or anything with an extension.
  matcher: ['/((?!api/|_next/|.*\\.[a-zA-Z0-9]+$).*)'],
};
```

`pageview` and `event` return immediately. Nothing is awaited, nothing throws,
and a collector that is down or slow cannot delay or break the page.

The proxy hands `flush()` to `waitUntil`, which keeps the function alive long
enough for the report to leave without the page waiting for it. On any other
serverless platform, do the same with whatever it calls that:

```ts
context.waitUntil(reporter.flush());
```

### Pages opened without a page load

**A page reached by a link inside the site never reaches the proxy.** Next.js,
and any app with a client-side router, fetches the next page in the background,
often before the click, and shows it without a page load. Counting those
background fetches would count pages nobody opened; ignoring them misses most of
a visit. So the proxy counts page loads only, and the browser says when the path
changes, through your own server, which reports it the same way:

```ts
// app/actions.ts
'use server';
import { headers } from 'next/headers';
import { after } from 'next/server';

export async function recordPageView(path: string) {
  const asked = await headers();
  reporter.pageview({
    url: `https://salon.example${path}`,
    ip: asked.get('cf-connecting-ip') ?? '',
    userAgent: asked.get('user-agent') ?? '',
    optedOut: asked.get('sec-gpc') === '1' || asked.get('dnt') === '1',
  });
  after(() => reporter.flush());
}
```

Call it from a client component whenever `usePathname()` changes, skipping the
first path, which was a page load the proxy has already counted. No key reaches
the browser, and nothing is stored on the device.

### Named events

Something worth counting that is not a page, such as a booking made, is an
event. Declare its name on the Tracking screen first: a name that is not
declared is recorded as the page view it also was.

```ts
// in a server action, as above
reporter.event('booking_made', {
  url: 'https://salon.example/book',
  ip: asked.get('cf-connecting-ip') ?? '',
  userAgent: asked.get('user-agent') ?? '',
});
```

### Trying it on your own machine

Give the reporter a test secret key (`sk_test_...`) while you build. Its reports
are taken from any host, `localhost` included, and the Tracking screen counts
them and lists the latest as they would have been recorded. They never become
figures, and they count against the month's events like any other hit.

## A secret key, and it refuses a publishable one

Every report asserts who the visitor was, because your server is the only thing
that saw them. A publishable key is public by design: it sits in the page source
of every site using it. If this endpoint accepted one, anybody who read it could
forge visitors and spend your events allowance while looking like real traffic.

So this needs `sk_`, the platform refuses anything else with a 401, and the
constructor refuses it earlier so the stack trace points at the line that is
wrong.

Keep the key on the server. It is the same key that can read your payments.

## What we do with what you send

The address and the User-Agent go into a hash with a salt that rotates at your
project's local midnight and is never written down, and neither is ever stored.
Same as what the browser snippet does with the address it arrives from, which is
why sites using either need no cookie banner. The full list is on the Tracking
screen.

Send the **visitor's** address, not your server's: read it from whatever header
your proxy sets (`cf-connecting-ip`, `x-real-ip`, or the entry your own proxy
appended to `x-forwarded-for`, at its right-hand end, since the left is whatever
the visitor sent). Your own address for everybody makes the whole site look
like one person. A report with no address still records; it just counts fewer
visitors than there were.

Send the **visitor's** User-Agent too. It is what tells a crawler from a person,
and without it a crawler is counted as a visitor and spends an event. A browser
driven by a program, such as headless Chrome under Playwright or Puppeteer, says
so in its User-Agent and is counted as a crawler as well, and a test key does
not count crawlers at all: a test checking that page views arrive should give
its browser an ordinary User-Agent.

Pass the **visitor's** opt-out. Somebody whose browser sends `Sec-GPC: 1` or
`DNT: 1` is not measured by the snippet, and only your server saw those headers,
so `optedOut` is how the same rule reaches this door. An opted-out hit is never
sent. Writing your own client instead, send `"opted_out": true` on the event and
the platform drops it before anything is derived from the address.

## Options

| option | default | |
|---|---|---|
| `origin` | required | Where the platform runs, no trailing path |
| `key` | required | `sk_live_...` |
| `batchSize` | `20` | Hits gathered before sending. Capped at 100 |
| `interval` | `1000` | How long a partly full batch waits, in ms |
| `timeout` | `10000` | Per-request, in ms. `0` disables it |
| `fetch` | global | Your own: a test double, a Worker's fetcher |
| `onError` | silence | The only place a failed report is visible |

`onError` defaults to doing nothing on purpose. During an outage the alternative
is a log line per page view, which fills your log budget with our problem.

## What it does not do

**It does not read anything.** There is no method here that returns figures: the
Traffic screen in the dashboard is where those are read, and a key that could
report and read would be a wider key than reporting needs.

**It does not retry.** A page view that failed to send is a page view lost, and
that is the right trade: a queue that grows during an outage is memory on
somebody's web server, and the figure it protects is a count.

**It does not trim your URLs.** The query string is dropped and `utm_*` kept on
arrival, in the one place that rule lives. Doing it here as well would be a
second implementation of it, and the day they disagreed your campaign tags would
go missing with nothing failing.
