@backendfree/webanalytics
v0.1.1
Published
Report page views to BackendFree from your own server, for the visitors and crawlers a browser snippet never sees.
Maintainers
Readme
@backendfree/webanalytics
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.
npm install @backendfree/webanalyticsUsing it
Report a page view where your server answers a page load. In Next.js that is
proxy.ts (middleware.ts before Next 16):
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:
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:
// 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.
// 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') ?? '',
});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.
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.
