@backendfree/webanalytics 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -2
- package/dist/index.d.ts +150 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +220 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +47 -4
- package/src/index.ts +312 -0
- package/src/types.ts +41 -0
package/README.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @backendfree/webanalytics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Report page views to BackendFree from your own server, for the visitors and
|
|
4
|
+
crawlers a browser snippet never sees.
|
|
5
|
+
|
|
6
|
+
The snippet on the Tracking screen covers people with JavaScript. This covers
|
|
7
|
+
everything else: crawlers, which mostly do not run scripts at all, and any
|
|
8
|
+
request you want measured before a page is rendered.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @backendfree/webanalytics
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Using it
|
|
15
|
+
|
|
16
|
+
Report a page view where your server answers a page load. In Next.js that is
|
|
17
|
+
`proxy.ts` (`middleware.ts` before Next 16):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server';
|
|
21
|
+
import { Reporter } from '@backendfree/webanalytics';
|
|
22
|
+
|
|
23
|
+
const reporter = new Reporter({
|
|
24
|
+
origin: 'https://backendfree.com',
|
|
25
|
+
key: process.env.BACKENDFREE_SECRET_KEY!,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function proxy(request: NextRequest, event: NextFetchEvent) {
|
|
29
|
+
// A page load, or a crawler, which sends no Sec-Fetch-Dest at all. Not the
|
|
30
|
+
// prefetches and data requests an app makes in the background.
|
|
31
|
+
const dest = request.headers.get('sec-fetch-dest');
|
|
32
|
+
if (request.method === 'GET' && (dest === null || dest === 'document')) {
|
|
33
|
+
reporter.pageview({
|
|
34
|
+
url: request.url,
|
|
35
|
+
referrer: request.headers.get('referer') ?? '',
|
|
36
|
+
ip: request.headers.get('cf-connecting-ip') ?? '',
|
|
37
|
+
userAgent: request.headers.get('user-agent') ?? '',
|
|
38
|
+
optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
|
|
39
|
+
});
|
|
40
|
+
event.waitUntil(reporter.flush());
|
|
41
|
+
}
|
|
42
|
+
return NextResponse.next();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const config = {
|
|
46
|
+
// Not the API routes, the framework's own files, or anything with an extension.
|
|
47
|
+
matcher: ['/((?!api/|_next/|.*\\.[a-zA-Z0-9]+$).*)'],
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`pageview` and `event` return immediately. Nothing is awaited, nothing throws,
|
|
52
|
+
and a collector that is down or slow cannot delay or break the page.
|
|
53
|
+
|
|
54
|
+
The proxy hands `flush()` to `waitUntil`, which keeps the function alive long
|
|
55
|
+
enough for the report to leave without the page waiting for it. On any other
|
|
56
|
+
serverless platform, do the same with whatever it calls that:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
context.waitUntil(reporter.flush());
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Pages opened without a page load
|
|
63
|
+
|
|
64
|
+
**A page reached by a link inside the site never reaches the proxy.** Next.js,
|
|
65
|
+
and any app with a client-side router, fetches the next page in the background,
|
|
66
|
+
often before the click, and shows it without a page load. Counting those
|
|
67
|
+
background fetches would count pages nobody opened; ignoring them misses most of
|
|
68
|
+
a visit. So the proxy counts page loads only, and the browser says when the path
|
|
69
|
+
changes, through your own server, which reports it the same way:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// app/actions.ts
|
|
73
|
+
'use server';
|
|
74
|
+
import { headers } from 'next/headers';
|
|
75
|
+
import { after } from 'next/server';
|
|
76
|
+
|
|
77
|
+
export async function recordPageView(path: string) {
|
|
78
|
+
const asked = await headers();
|
|
79
|
+
reporter.pageview({
|
|
80
|
+
url: `https://salon.example${path}`,
|
|
81
|
+
ip: asked.get('cf-connecting-ip') ?? '',
|
|
82
|
+
userAgent: asked.get('user-agent') ?? '',
|
|
83
|
+
optedOut: asked.get('sec-gpc') === '1' || asked.get('dnt') === '1',
|
|
84
|
+
});
|
|
85
|
+
after(() => reporter.flush());
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Call it from a client component whenever `usePathname()` changes, skipping the
|
|
90
|
+
first path, which was a page load the proxy has already counted. No key reaches
|
|
91
|
+
the browser, and nothing is stored on the device.
|
|
92
|
+
|
|
93
|
+
### Named events
|
|
94
|
+
|
|
95
|
+
Something worth counting that is not a page, such as a booking made, is an
|
|
96
|
+
event. Declare its name on the Tracking screen first: a name that is not
|
|
97
|
+
declared is recorded as the page view it also was.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// in a server action, as above
|
|
101
|
+
reporter.event('booking_made', {
|
|
102
|
+
url: 'https://salon.example/book',
|
|
103
|
+
ip: asked.get('cf-connecting-ip') ?? '',
|
|
104
|
+
userAgent: asked.get('user-agent') ?? '',
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## A secret key, and it refuses a publishable one
|
|
109
|
+
|
|
110
|
+
Every report asserts who the visitor was, because your server is the only thing
|
|
111
|
+
that saw them. A publishable key is public by design: it sits in the page source
|
|
112
|
+
of every site using it. If this endpoint accepted one, anybody who read it could
|
|
113
|
+
forge visitors and spend your events allowance while looking like real traffic.
|
|
114
|
+
|
|
115
|
+
So this needs `sk_`, the platform refuses anything else with a 401, and the
|
|
116
|
+
constructor refuses it earlier so the stack trace points at the line that is
|
|
117
|
+
wrong.
|
|
118
|
+
|
|
119
|
+
Keep the key on the server. It is the same key that can read your payments.
|
|
120
|
+
|
|
121
|
+
## What we do with what you send
|
|
122
|
+
|
|
123
|
+
The address and the User-Agent go into a hash with a salt that rotates at your
|
|
124
|
+
project's local midnight and is never written down, and neither is ever stored.
|
|
125
|
+
Same as what the browser snippet does with the address it arrives from, which is
|
|
126
|
+
why sites using either need no cookie banner. The full list is on the Tracking
|
|
127
|
+
screen.
|
|
128
|
+
|
|
129
|
+
Send the **visitor's** address, not your server's: read it from whatever header
|
|
130
|
+
your proxy sets (`cf-connecting-ip`, `x-real-ip`, or the entry your own proxy
|
|
131
|
+
appended to `x-forwarded-for`, at its right-hand end, since the left is whatever
|
|
132
|
+
the visitor sent). Your own address for everybody makes the whole site look
|
|
133
|
+
like one person. A report with no address still records; it just counts fewer
|
|
134
|
+
visitors than there were.
|
|
135
|
+
|
|
136
|
+
Send the **visitor's** User-Agent too. It is what tells a crawler from a person,
|
|
137
|
+
and without it a crawler is counted as a visitor and spends an event.
|
|
138
|
+
|
|
139
|
+
Pass the **visitor's** opt-out. Somebody whose browser sends `Sec-GPC: 1` or
|
|
140
|
+
`DNT: 1` is not measured by the snippet, and only your server saw those headers,
|
|
141
|
+
so `optedOut` is how the same rule reaches this door. An opted-out hit is never
|
|
142
|
+
sent. Writing your own client instead, send `"opted_out": true` on the event and
|
|
143
|
+
the platform drops it before anything is derived from the address.
|
|
144
|
+
|
|
145
|
+
## Options
|
|
146
|
+
|
|
147
|
+
| option | default | |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `origin` | required | Where the platform runs, no trailing path |
|
|
150
|
+
| `key` | required | `sk_live_...` |
|
|
151
|
+
| `batchSize` | `20` | Hits gathered before sending. Capped at 100 |
|
|
152
|
+
| `interval` | `1000` | How long a partly full batch waits, in ms |
|
|
153
|
+
| `timeout` | `10000` | Per-request, in ms. `0` disables it |
|
|
154
|
+
| `fetch` | global | Your own: a test double, a Worker's fetcher |
|
|
155
|
+
| `onError` | silence | The only place a failed report is visible |
|
|
156
|
+
|
|
157
|
+
`onError` defaults to doing nothing on purpose. During an outage the alternative
|
|
158
|
+
is a log line per page view, which fills your log budget with our problem.
|
|
159
|
+
|
|
160
|
+
## What it does not do
|
|
161
|
+
|
|
162
|
+
**It does not read anything.** There is no method here that returns figures: the
|
|
163
|
+
Traffic screen in the dashboard is where those are read, and a key that could
|
|
164
|
+
report and read would be a wider key than reporting needs.
|
|
165
|
+
|
|
166
|
+
**It does not retry.** A page view that failed to send is a page view lost, and
|
|
167
|
+
that is the right trade: a queue that grows during an outage is memory on
|
|
168
|
+
somebody's web server, and the figure it protects is a count.
|
|
169
|
+
|
|
170
|
+
**It does not trim your URLs.** The query string is dropped and `utm_*` kept on
|
|
171
|
+
arrival, in the one place that rule lives. Doing it here as well would be a
|
|
172
|
+
second implementation of it, and the day they disagreed your campaign tags would
|
|
173
|
+
go missing with nothing failing.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @backendfree/webanalytics
|
|
3
|
+
*
|
|
4
|
+
* Reporting page views from your own server, for the visitors a browser snippet
|
|
5
|
+
* never sees.
|
|
6
|
+
*
|
|
7
|
+
* import { Reporter } from '@backendfree/webanalytics';
|
|
8
|
+
*
|
|
9
|
+
* const reporter = new Reporter({ origin, key: process.env.SECRET_KEY });
|
|
10
|
+
*
|
|
11
|
+
* // where your server answers a page load: a Next.js proxy, an edge worker
|
|
12
|
+
* reporter.pageview({
|
|
13
|
+
* url: request.url,
|
|
14
|
+
* referrer: request.headers.get('referer') ?? '',
|
|
15
|
+
* ip: request.headers.get('cf-connecting-ip') ?? '',
|
|
16
|
+
* userAgent: request.headers.get('user-agent') ?? '',
|
|
17
|
+
* optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
|
|
18
|
+
* });
|
|
19
|
+
* event.waitUntil(reporter.flush());
|
|
20
|
+
*
|
|
21
|
+
* Five things are worth knowing before using it.
|
|
22
|
+
*
|
|
23
|
+
* **This needs a secret key, and it throws on a publishable one.** Every report
|
|
24
|
+
* asserts who the visitor was, because your server is the only thing that saw
|
|
25
|
+
* them. A publishable key sits in the page source of every site using it, so
|
|
26
|
+
* accepting one here would let anybody who read it forge visitors and spend
|
|
27
|
+
* your month while looking like real traffic. The platform refuses it too; this
|
|
28
|
+
* refuses at construction, where the stack trace points at the line that is
|
|
29
|
+
* wrong.
|
|
30
|
+
*
|
|
31
|
+
* **It never throws while reporting, and never delays a response.** A page must
|
|
32
|
+
* not fail, or wait, because measuring it failed. `pageview` and `event` return
|
|
33
|
+
* immediately; sending happens in the background and a failure is dropped. Call
|
|
34
|
+
* `flush()` where you actually want to wait, which on a serverless platform
|
|
35
|
+
* means inside `waitUntil`.
|
|
36
|
+
*
|
|
37
|
+
* **It is not built on `Client`.** The collector is not `/v1`: no ETags, no
|
|
38
|
+
* idempotency keys, no error envelope, no pagination and no request allowance.
|
|
39
|
+
* Routing reports through a client built for a different API would attach four
|
|
40
|
+
* things the endpoint ignores.
|
|
41
|
+
*
|
|
42
|
+
* **What you send about a visitor does not survive the request.** The address
|
|
43
|
+
* and the agent go into a hash with a salt that rotates at your project's local
|
|
44
|
+
* midnight and is never written down, and neither is ever stored. That is the
|
|
45
|
+
* same thing the browser snippet does with the address it arrives from, which
|
|
46
|
+
* is why sites using either need no cookie banner. A visitor who sent GPC or
|
|
47
|
+
* DNT is not measured at all: pass `optedOut` and the hit is never sent.
|
|
48
|
+
*
|
|
49
|
+
* **Crawlers are why this exists.** Most never run JavaScript, so the snippet
|
|
50
|
+
* does not see them at all. Reported from your server they are classified,
|
|
51
|
+
* counted and capped, and they never spend your events allowance.
|
|
52
|
+
*/
|
|
53
|
+
/** Where the collector lives under an origin. */
|
|
54
|
+
export declare const COLLECT_PATH = "/collect/s";
|
|
55
|
+
/** What the platform takes in one report. Sending more is a 400. */
|
|
56
|
+
export declare const MAX_BATCH = 100;
|
|
57
|
+
/** Ten seconds, matching core's client: long enough for a cold start. */
|
|
58
|
+
export declare const DEFAULT_TIMEOUT = 10000;
|
|
59
|
+
/** How long a partly full batch waits for company before going anyway. */
|
|
60
|
+
export declare const DEFAULT_INTERVAL = 1000;
|
|
61
|
+
export interface ReporterOptions {
|
|
62
|
+
/** Where the platform runs, with no trailing path: `https://backendfree.com`. */
|
|
63
|
+
origin: string;
|
|
64
|
+
/** `sk_live_...`. A publishable key throws: see the note above. */
|
|
65
|
+
key: string;
|
|
66
|
+
/** Swap in your own `fetch`: a test double, or a Worker's bound fetcher. */
|
|
67
|
+
fetch?: typeof globalThis.fetch;
|
|
68
|
+
/** Per-request timeout in milliseconds. `0` disables it. */
|
|
69
|
+
timeout?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Hits to gather before sending. 1 sends every hit on its own, which is
|
|
72
|
+
* simplest and costs a request per page view. Capped at `MAX_BATCH`.
|
|
73
|
+
*/
|
|
74
|
+
batchSize?: number;
|
|
75
|
+
/**
|
|
76
|
+
* How long a partly full batch waits, in milliseconds. `0` sends on the next
|
|
77
|
+
* turn of the event loop rather than never: a site with one visitor an hour
|
|
78
|
+
* would otherwise hold that visit until the next one arrived.
|
|
79
|
+
*/
|
|
80
|
+
interval?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Called when a batch could not be sent. Nothing is retried and nothing is
|
|
83
|
+
* thrown, so this is the only place a failure is visible. Default: silence,
|
|
84
|
+
* because the alternative is an unhandled rejection taking down a worker
|
|
85
|
+
* over an analytics call.
|
|
86
|
+
*/
|
|
87
|
+
onError?: (error: unknown) => void;
|
|
88
|
+
}
|
|
89
|
+
/** One page view or named event to report. */
|
|
90
|
+
export interface HitInput {
|
|
91
|
+
/** The full URL the visitor was on. The query is dropped except `utm_*`. */
|
|
92
|
+
url: string;
|
|
93
|
+
/** The referrer, reduced to a hostname on arrival. */
|
|
94
|
+
referrer?: string;
|
|
95
|
+
/**
|
|
96
|
+
* @deprecated Ignored. `pageview()` always reports a page view, and
|
|
97
|
+
* `event(name, hit)` takes the event's name as its first argument.
|
|
98
|
+
*/
|
|
99
|
+
name?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The visitor's address, hashed on arrival and never stored. Read it from
|
|
102
|
+
* whatever header your proxy sets: `cf-connecting-ip`, `x-real-ip`, or the
|
|
103
|
+
* far end of `x-forwarded-for`. Sending your own server's address instead
|
|
104
|
+
* makes every visitor look like one person.
|
|
105
|
+
*/
|
|
106
|
+
ip?: string;
|
|
107
|
+
/**
|
|
108
|
+
* The visitor's User-Agent. Kept only as one of three device words, and it
|
|
109
|
+
* is what tells a crawler from a person, so a report without it counts a
|
|
110
|
+
* crawler as a visitor and spends an event on it.
|
|
111
|
+
*/
|
|
112
|
+
userAgent?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Whether the visitor asked not to be measured: `sec-gpc: 1` or `dnt: 1` on
|
|
115
|
+
* their request. Only your server saw those headers, so only you can pass
|
|
116
|
+
* them on. An opted-out hit is dropped here and never sent, the same as the
|
|
117
|
+
* browser snippet does.
|
|
118
|
+
*/
|
|
119
|
+
optedOut?: boolean;
|
|
120
|
+
}
|
|
121
|
+
export declare class Reporter {
|
|
122
|
+
#private;
|
|
123
|
+
readonly origin: string;
|
|
124
|
+
constructor(options: ReporterOptions);
|
|
125
|
+
/** How many hits are waiting to be sent. */
|
|
126
|
+
get pending(): number;
|
|
127
|
+
/**
|
|
128
|
+
* Report one page view. Returns immediately and never throws.
|
|
129
|
+
*
|
|
130
|
+
* A hit with no `url` is dropped here rather than sent: the platform would
|
|
131
|
+
* drop it too, for having no hostname to match against the site list, and
|
|
132
|
+
* spending a request to find that out helps nobody.
|
|
133
|
+
*/
|
|
134
|
+
pageview(hit: HitInput): void;
|
|
135
|
+
/**
|
|
136
|
+
* Report a named event. Same rules, plus the name has to be one the Tracking
|
|
137
|
+
* screen declares, or it is recorded as the page view it also was.
|
|
138
|
+
*/
|
|
139
|
+
event(name: string, hit: HitInput): void;
|
|
140
|
+
/**
|
|
141
|
+
* Send everything queued and wait for it.
|
|
142
|
+
*
|
|
143
|
+
* Where a serverless platform needs this: `waitUntil(reporter.flush())` keeps
|
|
144
|
+
* the process alive long enough for the report to leave, and without it a
|
|
145
|
+
* function that returns immediately takes the queue with it.
|
|
146
|
+
*/
|
|
147
|
+
flush(): Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
export type { Hit, ReportResult } from './types.js';
|
|
150
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAMH,iDAAiD;AACjD,eAAO,MAAM,YAAY,eAAe,CAAC;AAEzC,oEAAoE;AACpE,eAAO,MAAM,SAAS,MAAM,CAAC;AAE7B,yEAAyE;AACzE,eAAO,MAAM,eAAe,QAAS,CAAC;AAEtC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED,8CAA8C;AAC9C,MAAM,WAAW,QAAQ;IACvB,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAC;IACZ,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,qBAAa,QAAQ;;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;gBAcZ,OAAO,EAAE,eAAe;IAiCpC,4CAA4C;IAC5C,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI;IAI7B;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI;IAIxC;;;;;;OAMG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAmG7B;AAED,YAAY,EAAE,GAAG,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @backendfree/webanalytics
|
|
3
|
+
*
|
|
4
|
+
* Reporting page views from your own server, for the visitors a browser snippet
|
|
5
|
+
* never sees.
|
|
6
|
+
*
|
|
7
|
+
* import { Reporter } from '@backendfree/webanalytics';
|
|
8
|
+
*
|
|
9
|
+
* const reporter = new Reporter({ origin, key: process.env.SECRET_KEY });
|
|
10
|
+
*
|
|
11
|
+
* // where your server answers a page load: a Next.js proxy, an edge worker
|
|
12
|
+
* reporter.pageview({
|
|
13
|
+
* url: request.url,
|
|
14
|
+
* referrer: request.headers.get('referer') ?? '',
|
|
15
|
+
* ip: request.headers.get('cf-connecting-ip') ?? '',
|
|
16
|
+
* userAgent: request.headers.get('user-agent') ?? '',
|
|
17
|
+
* optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
|
|
18
|
+
* });
|
|
19
|
+
* event.waitUntil(reporter.flush());
|
|
20
|
+
*
|
|
21
|
+
* Five things are worth knowing before using it.
|
|
22
|
+
*
|
|
23
|
+
* **This needs a secret key, and it throws on a publishable one.** Every report
|
|
24
|
+
* asserts who the visitor was, because your server is the only thing that saw
|
|
25
|
+
* them. A publishable key sits in the page source of every site using it, so
|
|
26
|
+
* accepting one here would let anybody who read it forge visitors and spend
|
|
27
|
+
* your month while looking like real traffic. The platform refuses it too; this
|
|
28
|
+
* refuses at construction, where the stack trace points at the line that is
|
|
29
|
+
* wrong.
|
|
30
|
+
*
|
|
31
|
+
* **It never throws while reporting, and never delays a response.** A page must
|
|
32
|
+
* not fail, or wait, because measuring it failed. `pageview` and `event` return
|
|
33
|
+
* immediately; sending happens in the background and a failure is dropped. Call
|
|
34
|
+
* `flush()` where you actually want to wait, which on a serverless platform
|
|
35
|
+
* means inside `waitUntil`.
|
|
36
|
+
*
|
|
37
|
+
* **It is not built on `Client`.** The collector is not `/v1`: no ETags, no
|
|
38
|
+
* idempotency keys, no error envelope, no pagination and no request allowance.
|
|
39
|
+
* Routing reports through a client built for a different API would attach four
|
|
40
|
+
* things the endpoint ignores.
|
|
41
|
+
*
|
|
42
|
+
* **What you send about a visitor does not survive the request.** The address
|
|
43
|
+
* and the agent go into a hash with a salt that rotates at your project's local
|
|
44
|
+
* midnight and is never written down, and neither is ever stored. That is the
|
|
45
|
+
* same thing the browser snippet does with the address it arrives from, which
|
|
46
|
+
* is why sites using either need no cookie banner. A visitor who sent GPC or
|
|
47
|
+
* DNT is not measured at all: pass `optedOut` and the hit is never sent.
|
|
48
|
+
*
|
|
49
|
+
* **Crawlers are why this exists.** Most never run JavaScript, so the snippet
|
|
50
|
+
* does not see them at all. Reported from your server they are classified,
|
|
51
|
+
* counted and capped, and they never spend your events allowance.
|
|
52
|
+
*/
|
|
53
|
+
import { ConfigError } from '@backendfree/core';
|
|
54
|
+
/** Where the collector lives under an origin. */
|
|
55
|
+
export const COLLECT_PATH = '/collect/s';
|
|
56
|
+
/** What the platform takes in one report. Sending more is a 400. */
|
|
57
|
+
export const MAX_BATCH = 100;
|
|
58
|
+
/** Ten seconds, matching core's client: long enough for a cold start. */
|
|
59
|
+
export const DEFAULT_TIMEOUT = 10_000;
|
|
60
|
+
/** How long a partly full batch waits for company before going anyway. */
|
|
61
|
+
export const DEFAULT_INTERVAL = 1_000;
|
|
62
|
+
export class Reporter {
|
|
63
|
+
origin;
|
|
64
|
+
#key;
|
|
65
|
+
#fetch;
|
|
66
|
+
#timeout;
|
|
67
|
+
#batchSize;
|
|
68
|
+
#interval;
|
|
69
|
+
#onError;
|
|
70
|
+
#queue = [];
|
|
71
|
+
#timer = null;
|
|
72
|
+
/** The send in flight, so `flush()` can wait for it as well as for the queue. */
|
|
73
|
+
#sending = Promise.resolve();
|
|
74
|
+
constructor(options) {
|
|
75
|
+
const origin = (options.origin ?? '').trim().replace(/\/+$/, '');
|
|
76
|
+
if (!origin) {
|
|
77
|
+
throw new ConfigError('origin_required', 'Pass the origin the platform runs on.');
|
|
78
|
+
}
|
|
79
|
+
const key = (options.key ?? '').trim();
|
|
80
|
+
if (!key) {
|
|
81
|
+
throw new ConfigError('key_required', 'Pass a project API key.');
|
|
82
|
+
}
|
|
83
|
+
// The mistake this package exists to refuse. A publishable key is public by
|
|
84
|
+
// design, and this endpoint takes the visitor's identity on trust.
|
|
85
|
+
if (key.startsWith('pk_')) {
|
|
86
|
+
throw new ConfigError('secret_key_required', 'Reporting needs a secret key. Every report asserts who the visitor was, so a ' +
|
|
87
|
+
'publishable key here would let anybody who read it out of your page source forge ' +
|
|
88
|
+
'visitors and spend your events allowance.');
|
|
89
|
+
}
|
|
90
|
+
this.origin = origin;
|
|
91
|
+
this.#key = key;
|
|
92
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
93
|
+
this.#timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
94
|
+
this.#batchSize = Math.min(Math.max(options.batchSize ?? 20, 1), MAX_BATCH);
|
|
95
|
+
this.#interval = Math.max(options.interval ?? DEFAULT_INTERVAL, 0);
|
|
96
|
+
this.#onError = options.onError ?? (() => { });
|
|
97
|
+
if (typeof this.#fetch !== 'function') {
|
|
98
|
+
throw new ConfigError('fetch_required', 'This runtime has no fetch. Pass one.');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** How many hits are waiting to be sent. */
|
|
102
|
+
get pending() {
|
|
103
|
+
return this.#queue.length;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Report one page view. Returns immediately and never throws.
|
|
107
|
+
*
|
|
108
|
+
* A hit with no `url` is dropped here rather than sent: the platform would
|
|
109
|
+
* drop it too, for having no hostname to match against the site list, and
|
|
110
|
+
* spending a request to find that out helps nobody.
|
|
111
|
+
*/
|
|
112
|
+
pageview(hit) {
|
|
113
|
+
this.#add(hit, '');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Report a named event. Same rules, plus the name has to be one the Tracking
|
|
117
|
+
* screen declares, or it is recorded as the page view it also was.
|
|
118
|
+
*/
|
|
119
|
+
event(name, hit) {
|
|
120
|
+
this.#add(hit, name);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Send everything queued and wait for it.
|
|
124
|
+
*
|
|
125
|
+
* Where a serverless platform needs this: `waitUntil(reporter.flush())` keeps
|
|
126
|
+
* the process alive long enough for the report to leave, and without it a
|
|
127
|
+
* function that returns immediately takes the queue with it.
|
|
128
|
+
*/
|
|
129
|
+
async flush() {
|
|
130
|
+
this.#cancelTimer();
|
|
131
|
+
while (this.#queue.length) {
|
|
132
|
+
await this.#send(this.#queue.splice(0, this.#batchSize));
|
|
133
|
+
}
|
|
134
|
+
await this.#sending;
|
|
135
|
+
}
|
|
136
|
+
// --- the queue ---------------------------------------------------------------
|
|
137
|
+
#add(hit, name) {
|
|
138
|
+
// Before anything else, so not even a url of theirs is queued.
|
|
139
|
+
if (hit?.optedOut) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const url = (hit?.url ?? '').trim();
|
|
143
|
+
if (!url) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.#queue.push({
|
|
147
|
+
url,
|
|
148
|
+
referrer: hit.referrer ?? '',
|
|
149
|
+
name,
|
|
150
|
+
ip: hit.ip ?? '',
|
|
151
|
+
user_agent: hit.userAgent ?? '',
|
|
152
|
+
});
|
|
153
|
+
if (this.#queue.length >= this.#batchSize) {
|
|
154
|
+
this.#cancelTimer();
|
|
155
|
+
this.#launch(this.#queue.splice(0, this.#batchSize));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.#arm();
|
|
159
|
+
}
|
|
160
|
+
#arm() {
|
|
161
|
+
if (this.#timer !== null) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.#timer = setTimeout(() => {
|
|
165
|
+
this.#timer = null;
|
|
166
|
+
this.#launch(this.#queue.splice(0, this.#batchSize));
|
|
167
|
+
}, this.#interval);
|
|
168
|
+
// Node keeps the process alive for a pending timer, so a script that has
|
|
169
|
+
// finished its work would hang for the interval waiting to report. The
|
|
170
|
+
// browser and Workers have no such method, hence the guard.
|
|
171
|
+
this.#timer?.unref?.();
|
|
172
|
+
}
|
|
173
|
+
#cancelTimer() {
|
|
174
|
+
if (this.#timer !== null) {
|
|
175
|
+
clearTimeout(this.#timer);
|
|
176
|
+
this.#timer = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Start a send without waiting for it, keeping it reachable for `flush()`. */
|
|
180
|
+
#launch(batch) {
|
|
181
|
+
this.#sending = this.#sending.then(() => this.#send(batch));
|
|
182
|
+
}
|
|
183
|
+
async #send(batch) {
|
|
184
|
+
if (!batch.length) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const controller = this.#timeout > 0 ? new AbortController() : null;
|
|
188
|
+
const timer = controller ? setTimeout(() => controller.abort(), this.#timeout) : null;
|
|
189
|
+
try {
|
|
190
|
+
const response = await this.#fetch(`${this.origin}${COLLECT_PATH}`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: {
|
|
193
|
+
authorization: `Bearer ${this.#key}`,
|
|
194
|
+
'content-type': 'application/json',
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({ events: batch }),
|
|
197
|
+
signal: controller?.signal,
|
|
198
|
+
});
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
// Read, so the message says which of the four refusals it was rather
|
|
201
|
+
// than only the status. The platform answers a small JSON object.
|
|
202
|
+
const detail = await response.text().catch(() => '');
|
|
203
|
+
throw new Error(`The collector answered ${response.status}. ${detail}`.trim());
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
// Swallowed on purpose, and this is the only place a failure surfaces. A
|
|
208
|
+
// rejected promise nobody is awaiting is an unhandled rejection, which on
|
|
209
|
+
// several runtimes takes the whole worker down: measuring a page must not
|
|
210
|
+
// be able to stop it being served.
|
|
211
|
+
this.#onError(error);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
if (timer) {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What goes over the wire, in the collector's own spelling.
|
|
3
|
+
*
|
|
4
|
+
* Snake case here and camel case on `HitInput`, deliberately: the wire format
|
|
5
|
+
* is the platform's and changing it is a breaking change for every caller in
|
|
6
|
+
* every language, while the shape a TypeScript developer types is theirs. One
|
|
7
|
+
* conversion, in `#add`, rather than a wire format that reads as if it were
|
|
8
|
+
* designed for one language.
|
|
9
|
+
*/
|
|
10
|
+
/** One reported hit, as `POST /collect/s` takes it. */
|
|
11
|
+
export interface Hit {
|
|
12
|
+
url: string;
|
|
13
|
+
referrer: string;
|
|
14
|
+
/** Empty for a page view. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Hashed on arrival with the day's salt, and never stored. */
|
|
17
|
+
ip: string;
|
|
18
|
+
/** Kept only as one of three device words, and used to spot a crawler. */
|
|
19
|
+
user_agent: string;
|
|
20
|
+
/**
|
|
21
|
+
* The visitor opted out. Never sent by this package, which drops an opted-out
|
|
22
|
+
* hit before anything leaves; a hand-written client sends it and the platform
|
|
23
|
+
* drops the event before anything is derived from the address.
|
|
24
|
+
*/
|
|
25
|
+
opted_out?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** What the collector answers when it took the report. */
|
|
28
|
+
export interface ReportResult {
|
|
29
|
+
/** Events in the request, including any it went on to drop. */
|
|
30
|
+
received: number;
|
|
31
|
+
/**
|
|
32
|
+
* Events that became a row. Lower than `received` when the month's allowance
|
|
33
|
+
* ran out, when a hostname is not on the site list, or when the module is off.
|
|
34
|
+
* A crawler counts as recorded and spends no allowance.
|
|
35
|
+
*/
|
|
36
|
+
recorded: number;
|
|
37
|
+
/** False for a test key, whose hits are counted on the Tracking screen and kept nowhere. */
|
|
38
|
+
livemode: boolean;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,uDAAuD;AACvD,MAAM,WAAW,GAAG;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,EAAE,EAAE,MAAM,CAAC;IACX,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,0DAA0D;AAC1D,MAAM,WAAW,YAAY;IAC3B,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,4FAA4F;IAC5F,QAAQ,EAAE,OAAO,CAAC;CACnB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What goes over the wire, in the collector's own spelling.
|
|
3
|
+
*
|
|
4
|
+
* Snake case here and camel case on `HitInput`, deliberately: the wire format
|
|
5
|
+
* is the platform's and changing it is a breaking change for every caller in
|
|
6
|
+
* every language, while the shape a TypeScript developer types is theirs. One
|
|
7
|
+
* conversion, in `#add`, rather than a wire format that reads as if it were
|
|
8
|
+
* designed for one language.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backendfree/webanalytics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Report page views to BackendFree from your own server, for the visitors and crawlers a browser snippet never sees.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"backendfree",
|
|
7
|
+
"analytics",
|
|
8
|
+
"cookieless",
|
|
9
|
+
"privacy",
|
|
10
|
+
"sdk",
|
|
11
|
+
"typescript"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "BackendFree",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"!dist/.tsbuildinfo",
|
|
32
|
+
"src",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@backendfree/core": "^0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"clean": "tsc --build --clean && rm -rf dist",
|
|
43
|
+
"build": "tsc --build",
|
|
44
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
45
|
+
"prepare": "npm run build",
|
|
46
|
+
"prepack": "npm run clean && npm run build",
|
|
47
|
+
"prepublishOnly": "npm test"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @backendfree/webanalytics
|
|
3
|
+
*
|
|
4
|
+
* Reporting page views from your own server, for the visitors a browser snippet
|
|
5
|
+
* never sees.
|
|
6
|
+
*
|
|
7
|
+
* import { Reporter } from '@backendfree/webanalytics';
|
|
8
|
+
*
|
|
9
|
+
* const reporter = new Reporter({ origin, key: process.env.SECRET_KEY });
|
|
10
|
+
*
|
|
11
|
+
* // where your server answers a page load: a Next.js proxy, an edge worker
|
|
12
|
+
* reporter.pageview({
|
|
13
|
+
* url: request.url,
|
|
14
|
+
* referrer: request.headers.get('referer') ?? '',
|
|
15
|
+
* ip: request.headers.get('cf-connecting-ip') ?? '',
|
|
16
|
+
* userAgent: request.headers.get('user-agent') ?? '',
|
|
17
|
+
* optedOut: request.headers.get('sec-gpc') === '1' || request.headers.get('dnt') === '1',
|
|
18
|
+
* });
|
|
19
|
+
* event.waitUntil(reporter.flush());
|
|
20
|
+
*
|
|
21
|
+
* Five things are worth knowing before using it.
|
|
22
|
+
*
|
|
23
|
+
* **This needs a secret key, and it throws on a publishable one.** Every report
|
|
24
|
+
* asserts who the visitor was, because your server is the only thing that saw
|
|
25
|
+
* them. A publishable key sits in the page source of every site using it, so
|
|
26
|
+
* accepting one here would let anybody who read it forge visitors and spend
|
|
27
|
+
* your month while looking like real traffic. The platform refuses it too; this
|
|
28
|
+
* refuses at construction, where the stack trace points at the line that is
|
|
29
|
+
* wrong.
|
|
30
|
+
*
|
|
31
|
+
* **It never throws while reporting, and never delays a response.** A page must
|
|
32
|
+
* not fail, or wait, because measuring it failed. `pageview` and `event` return
|
|
33
|
+
* immediately; sending happens in the background and a failure is dropped. Call
|
|
34
|
+
* `flush()` where you actually want to wait, which on a serverless platform
|
|
35
|
+
* means inside `waitUntil`.
|
|
36
|
+
*
|
|
37
|
+
* **It is not built on `Client`.** The collector is not `/v1`: no ETags, no
|
|
38
|
+
* idempotency keys, no error envelope, no pagination and no request allowance.
|
|
39
|
+
* Routing reports through a client built for a different API would attach four
|
|
40
|
+
* things the endpoint ignores.
|
|
41
|
+
*
|
|
42
|
+
* **What you send about a visitor does not survive the request.** The address
|
|
43
|
+
* and the agent go into a hash with a salt that rotates at your project's local
|
|
44
|
+
* midnight and is never written down, and neither is ever stored. That is the
|
|
45
|
+
* same thing the browser snippet does with the address it arrives from, which
|
|
46
|
+
* is why sites using either need no cookie banner. A visitor who sent GPC or
|
|
47
|
+
* DNT is not measured at all: pass `optedOut` and the hit is never sent.
|
|
48
|
+
*
|
|
49
|
+
* **Crawlers are why this exists.** Most never run JavaScript, so the snippet
|
|
50
|
+
* does not see them at all. Reported from your server they are classified,
|
|
51
|
+
* counted and capped, and they never spend your events allowance.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { ConfigError } from '@backendfree/core';
|
|
55
|
+
|
|
56
|
+
import type { Hit, ReportResult } from './types.js';
|
|
57
|
+
|
|
58
|
+
/** Where the collector lives under an origin. */
|
|
59
|
+
export const COLLECT_PATH = '/collect/s';
|
|
60
|
+
|
|
61
|
+
/** What the platform takes in one report. Sending more is a 400. */
|
|
62
|
+
export const MAX_BATCH = 100;
|
|
63
|
+
|
|
64
|
+
/** Ten seconds, matching core's client: long enough for a cold start. */
|
|
65
|
+
export const DEFAULT_TIMEOUT = 10_000;
|
|
66
|
+
|
|
67
|
+
/** How long a partly full batch waits for company before going anyway. */
|
|
68
|
+
export const DEFAULT_INTERVAL = 1_000;
|
|
69
|
+
|
|
70
|
+
export interface ReporterOptions {
|
|
71
|
+
/** Where the platform runs, with no trailing path: `https://backendfree.com`. */
|
|
72
|
+
origin: string;
|
|
73
|
+
/** `sk_live_...`. A publishable key throws: see the note above. */
|
|
74
|
+
key: string;
|
|
75
|
+
/** Swap in your own `fetch`: a test double, or a Worker's bound fetcher. */
|
|
76
|
+
fetch?: typeof globalThis.fetch;
|
|
77
|
+
/** Per-request timeout in milliseconds. `0` disables it. */
|
|
78
|
+
timeout?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Hits to gather before sending. 1 sends every hit on its own, which is
|
|
81
|
+
* simplest and costs a request per page view. Capped at `MAX_BATCH`.
|
|
82
|
+
*/
|
|
83
|
+
batchSize?: number;
|
|
84
|
+
/**
|
|
85
|
+
* How long a partly full batch waits, in milliseconds. `0` sends on the next
|
|
86
|
+
* turn of the event loop rather than never: a site with one visitor an hour
|
|
87
|
+
* would otherwise hold that visit until the next one arrived.
|
|
88
|
+
*/
|
|
89
|
+
interval?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Called when a batch could not be sent. Nothing is retried and nothing is
|
|
92
|
+
* thrown, so this is the only place a failure is visible. Default: silence,
|
|
93
|
+
* because the alternative is an unhandled rejection taking down a worker
|
|
94
|
+
* over an analytics call.
|
|
95
|
+
*/
|
|
96
|
+
onError?: (error: unknown) => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One page view or named event to report. */
|
|
100
|
+
export interface HitInput {
|
|
101
|
+
/** The full URL the visitor was on. The query is dropped except `utm_*`. */
|
|
102
|
+
url: string;
|
|
103
|
+
/** The referrer, reduced to a hostname on arrival. */
|
|
104
|
+
referrer?: string;
|
|
105
|
+
/**
|
|
106
|
+
* @deprecated Ignored. `pageview()` always reports a page view, and
|
|
107
|
+
* `event(name, hit)` takes the event's name as its first argument.
|
|
108
|
+
*/
|
|
109
|
+
name?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The visitor's address, hashed on arrival and never stored. Read it from
|
|
112
|
+
* whatever header your proxy sets: `cf-connecting-ip`, `x-real-ip`, or the
|
|
113
|
+
* far end of `x-forwarded-for`. Sending your own server's address instead
|
|
114
|
+
* makes every visitor look like one person.
|
|
115
|
+
*/
|
|
116
|
+
ip?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The visitor's User-Agent. Kept only as one of three device words, and it
|
|
119
|
+
* is what tells a crawler from a person, so a report without it counts a
|
|
120
|
+
* crawler as a visitor and spends an event on it.
|
|
121
|
+
*/
|
|
122
|
+
userAgent?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Whether the visitor asked not to be measured: `sec-gpc: 1` or `dnt: 1` on
|
|
125
|
+
* their request. Only your server saw those headers, so only you can pass
|
|
126
|
+
* them on. An opted-out hit is dropped here and never sent, the same as the
|
|
127
|
+
* browser snippet does.
|
|
128
|
+
*/
|
|
129
|
+
optedOut?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class Reporter {
|
|
133
|
+
readonly origin: string;
|
|
134
|
+
|
|
135
|
+
readonly #key: string;
|
|
136
|
+
readonly #fetch: typeof globalThis.fetch;
|
|
137
|
+
readonly #timeout: number;
|
|
138
|
+
readonly #batchSize: number;
|
|
139
|
+
readonly #interval: number;
|
|
140
|
+
readonly #onError: (error: unknown) => void;
|
|
141
|
+
|
|
142
|
+
#queue: Hit[] = [];
|
|
143
|
+
#timer: ReturnType<typeof setTimeout> | null = null;
|
|
144
|
+
/** The send in flight, so `flush()` can wait for it as well as for the queue. */
|
|
145
|
+
#sending: Promise<void> = Promise.resolve();
|
|
146
|
+
|
|
147
|
+
constructor(options: ReporterOptions) {
|
|
148
|
+
const origin = (options.origin ?? '').trim().replace(/\/+$/, '');
|
|
149
|
+
if (!origin) {
|
|
150
|
+
throw new ConfigError('origin_required', 'Pass the origin the platform runs on.');
|
|
151
|
+
}
|
|
152
|
+
const key = (options.key ?? '').trim();
|
|
153
|
+
if (!key) {
|
|
154
|
+
throw new ConfigError('key_required', 'Pass a project API key.');
|
|
155
|
+
}
|
|
156
|
+
// The mistake this package exists to refuse. A publishable key is public by
|
|
157
|
+
// design, and this endpoint takes the visitor's identity on trust.
|
|
158
|
+
if (key.startsWith('pk_')) {
|
|
159
|
+
throw new ConfigError(
|
|
160
|
+
'secret_key_required',
|
|
161
|
+
'Reporting needs a secret key. Every report asserts who the visitor was, so a ' +
|
|
162
|
+
'publishable key here would let anybody who read it out of your page source forge ' +
|
|
163
|
+
'visitors and spend your events allowance.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.origin = origin;
|
|
168
|
+
this.#key = key;
|
|
169
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
170
|
+
this.#timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
171
|
+
this.#batchSize = Math.min(Math.max(options.batchSize ?? 20, 1), MAX_BATCH);
|
|
172
|
+
this.#interval = Math.max(options.interval ?? DEFAULT_INTERVAL, 0);
|
|
173
|
+
this.#onError = options.onError ?? (() => {});
|
|
174
|
+
|
|
175
|
+
if (typeof this.#fetch !== 'function') {
|
|
176
|
+
throw new ConfigError('fetch_required', 'This runtime has no fetch. Pass one.');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** How many hits are waiting to be sent. */
|
|
181
|
+
get pending(): number {
|
|
182
|
+
return this.#queue.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Report one page view. Returns immediately and never throws.
|
|
187
|
+
*
|
|
188
|
+
* A hit with no `url` is dropped here rather than sent: the platform would
|
|
189
|
+
* drop it too, for having no hostname to match against the site list, and
|
|
190
|
+
* spending a request to find that out helps nobody.
|
|
191
|
+
*/
|
|
192
|
+
pageview(hit: HitInput): void {
|
|
193
|
+
this.#add(hit, '');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Report a named event. Same rules, plus the name has to be one the Tracking
|
|
198
|
+
* screen declares, or it is recorded as the page view it also was.
|
|
199
|
+
*/
|
|
200
|
+
event(name: string, hit: HitInput): void {
|
|
201
|
+
this.#add(hit, name);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Send everything queued and wait for it.
|
|
206
|
+
*
|
|
207
|
+
* Where a serverless platform needs this: `waitUntil(reporter.flush())` keeps
|
|
208
|
+
* the process alive long enough for the report to leave, and without it a
|
|
209
|
+
* function that returns immediately takes the queue with it.
|
|
210
|
+
*/
|
|
211
|
+
async flush(): Promise<void> {
|
|
212
|
+
this.#cancelTimer();
|
|
213
|
+
while (this.#queue.length) {
|
|
214
|
+
await this.#send(this.#queue.splice(0, this.#batchSize));
|
|
215
|
+
}
|
|
216
|
+
await this.#sending;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- the queue ---------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
#add(hit: HitInput, name: string): void {
|
|
222
|
+
// Before anything else, so not even a url of theirs is queued.
|
|
223
|
+
if (hit?.optedOut) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const url = (hit?.url ?? '').trim();
|
|
227
|
+
if (!url) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.#queue.push({
|
|
232
|
+
url,
|
|
233
|
+
referrer: hit.referrer ?? '',
|
|
234
|
+
name,
|
|
235
|
+
ip: hit.ip ?? '',
|
|
236
|
+
user_agent: hit.userAgent ?? '',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (this.#queue.length >= this.#batchSize) {
|
|
240
|
+
this.#cancelTimer();
|
|
241
|
+
this.#launch(this.#queue.splice(0, this.#batchSize));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.#arm();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#arm(): void {
|
|
248
|
+
if (this.#timer !== null) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
this.#timer = setTimeout(() => {
|
|
252
|
+
this.#timer = null;
|
|
253
|
+
this.#launch(this.#queue.splice(0, this.#batchSize));
|
|
254
|
+
}, this.#interval);
|
|
255
|
+
// Node keeps the process alive for a pending timer, so a script that has
|
|
256
|
+
// finished its work would hang for the interval waiting to report. The
|
|
257
|
+
// browser and Workers have no such method, hence the guard.
|
|
258
|
+
this.#timer?.unref?.();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#cancelTimer(): void {
|
|
262
|
+
if (this.#timer !== null) {
|
|
263
|
+
clearTimeout(this.#timer);
|
|
264
|
+
this.#timer = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Start a send without waiting for it, keeping it reachable for `flush()`. */
|
|
269
|
+
#launch(batch: Hit[]): void {
|
|
270
|
+
this.#sending = this.#sending.then(() => this.#send(batch));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async #send(batch: Hit[]): Promise<void> {
|
|
274
|
+
if (!batch.length) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const controller = this.#timeout > 0 ? new AbortController() : null;
|
|
279
|
+
const timer = controller ? setTimeout(() => controller.abort(), this.#timeout) : null;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const response = await this.#fetch(`${this.origin}${COLLECT_PATH}`, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: {
|
|
285
|
+
authorization: `Bearer ${this.#key}`,
|
|
286
|
+
'content-type': 'application/json',
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify({ events: batch }),
|
|
289
|
+
signal: controller?.signal,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
// Read, so the message says which of the four refusals it was rather
|
|
294
|
+
// than only the status. The platform answers a small JSON object.
|
|
295
|
+
const detail = await response.text().catch(() => '');
|
|
296
|
+
throw new Error(`The collector answered ${response.status}. ${detail}`.trim());
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// Swallowed on purpose, and this is the only place a failure surfaces. A
|
|
300
|
+
// rejected promise nobody is awaiting is an unhandled rejection, which on
|
|
301
|
+
// several runtimes takes the whole worker down: measuring a page must not
|
|
302
|
+
// be able to stop it being served.
|
|
303
|
+
this.#onError(error);
|
|
304
|
+
} finally {
|
|
305
|
+
if (timer) {
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export type { Hit, ReportResult } from './types.js';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What goes over the wire, in the collector's own spelling.
|
|
3
|
+
*
|
|
4
|
+
* Snake case here and camel case on `HitInput`, deliberately: the wire format
|
|
5
|
+
* is the platform's and changing it is a breaking change for every caller in
|
|
6
|
+
* every language, while the shape a TypeScript developer types is theirs. One
|
|
7
|
+
* conversion, in `#add`, rather than a wire format that reads as if it were
|
|
8
|
+
* designed for one language.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One reported hit, as `POST /collect/s` takes it. */
|
|
12
|
+
export interface Hit {
|
|
13
|
+
url: string;
|
|
14
|
+
referrer: string;
|
|
15
|
+
/** Empty for a page view. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Hashed on arrival with the day's salt, and never stored. */
|
|
18
|
+
ip: string;
|
|
19
|
+
/** Kept only as one of three device words, and used to spot a crawler. */
|
|
20
|
+
user_agent: string;
|
|
21
|
+
/**
|
|
22
|
+
* The visitor opted out. Never sent by this package, which drops an opted-out
|
|
23
|
+
* hit before anything leaves; a hand-written client sends it and the platform
|
|
24
|
+
* drops the event before anything is derived from the address.
|
|
25
|
+
*/
|
|
26
|
+
opted_out?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the collector answers when it took the report. */
|
|
30
|
+
export interface ReportResult {
|
|
31
|
+
/** Events in the request, including any it went on to drop. */
|
|
32
|
+
received: number;
|
|
33
|
+
/**
|
|
34
|
+
* Events that became a row. Lower than `received` when the month's allowance
|
|
35
|
+
* ran out, when a hostname is not on the site list, or when the module is off.
|
|
36
|
+
* A crawler counts as recorded and spends no allowance.
|
|
37
|
+
*/
|
|
38
|
+
recorded: number;
|
|
39
|
+
/** False for a test key, whose hits are counted on the Tracking screen and kept nowhere. */
|
|
40
|
+
livemode: boolean;
|
|
41
|
+
}
|