@wtfalch/reporting 0.1.1 → 0.2.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/dist/index.d.ts CHANGED
@@ -3,7 +3,11 @@ export * from './schema.js';
3
3
  export * from './tables.js';
4
4
  export * from './types.js';
5
5
  export { alertKind, projectDetail } from './alerts.js';
6
- export { DEFAULT_SETTINGS, RETENTION_BOUNDS, SETTING_KEYS } from './settings.js';
6
+ export { CONSENT_MODES, DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
7
+ export * from './analytics/schema.js';
8
+ export { createCollector, type CollectorOptions, type Sites } from './analytics/collector.js';
9
+ export { analyticsRecent, analyticsSeries, analyticsWeekly, hideSmallGroups, type Grain, type RecentEvent, type RecentOptions, type SeriesOptions, type SeriesPoint, type WeeklyPoint, } from './analytics/reader.js';
10
+ export type { AnalyticsRow } from './analytics/write.js';
7
11
  /**
8
12
  * The host's one instance, built once over its drizzle handle and its
9
13
  * logger. Framework-neutral: `defer` is how the host says "after this unit of
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { alertRow } from './alerts.js';
2
+ import { analyticsRecent, analyticsSeries, analyticsWeekly } from './analytics/reader.js';
3
+ import { ANALYTICS_NAME_PATTERN, normalisePath, propsSchema } from './analytics/schema.js';
4
+ import { insertAnalytics } from './analytics/write.js';
2
5
  import { eventsPage } from './reader.js';
3
6
  import { siteSchema } from './schema.js';
4
7
  import { getSettings, setSettings } from './settings.js';
@@ -8,7 +11,10 @@ export * from './schema.js';
8
11
  export * from './tables.js';
9
12
  export * from './types.js';
10
13
  export { alertKind, projectDetail } from './alerts.js';
11
- export { DEFAULT_SETTINGS, RETENTION_BOUNDS, SETTING_KEYS } from './settings.js';
14
+ export { CONSENT_MODES, DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
15
+ export * from './analytics/schema.js';
16
+ export { createCollector } from './analytics/collector.js';
17
+ export { analyticsRecent, analyticsSeries, analyticsWeekly, hideSmallGroups, } from './analytics/reader.js';
12
18
  function modeFromEnv() {
13
19
  const env = process.env.NODE_ENV;
14
20
  return env === 'development' || env === 'test' ? env : 'production';
@@ -39,7 +45,6 @@ export function createReporting(options) {
39
45
  });
40
46
  const reporting = {
41
47
  site,
42
- db: options.db,
43
48
  log: options.log,
44
49
  tables,
45
50
  event: (input) => writer.event(input),
@@ -76,6 +81,42 @@ export function createReporting(options) {
76
81
  return change;
77
82
  },
78
83
  },
84
+ analytics: {
85
+ async track(input) {
86
+ try {
87
+ if (!ANALYTICS_NAME_PATTERN.test(input.name)) {
88
+ throw new Error(`analytics.track: "${input.name}" is not a name`);
89
+ }
90
+ const props = propsSchema.parse(input.props ?? {});
91
+ const at = input.occurredAt ?? now();
92
+ await insertAnalytics(options.db, [
93
+ {
94
+ occurredAt: at,
95
+ receivedAt: now(),
96
+ site,
97
+ tenantId: input.tenantId ?? null,
98
+ visitorId: input.visitorId ?? null,
99
+ sessionId: input.sessionId ?? null,
100
+ userId: input.userId ?? null,
101
+ name: input.name,
102
+ path: (options.normalisePath ?? normalisePath)(input.path ?? '/'),
103
+ referrerHost: null,
104
+ device: input.device ?? 'unknown',
105
+ country: input.country ?? null,
106
+ props,
107
+ },
108
+ ]);
109
+ }
110
+ catch (error) {
111
+ if (mode !== 'production')
112
+ throw error;
113
+ options.log.error({ err: describe(error), name: input.name }, 'reporting: track failed');
114
+ }
115
+ },
116
+ series: (opts) => analyticsSeries(options.db, opts),
117
+ weekly: (opts) => analyticsWeekly(options.db, opts),
118
+ recent: (opts) => analyticsRecent(options.db, opts),
119
+ },
79
120
  stats: () => writer.stats(),
80
121
  };
81
122
  return reporting;
@@ -48,7 +48,9 @@ create table if not exists reporting_events (
48
48
  -- provider response or a request body sneaks in whole. `strict`, because
49
49
  -- lax mode unwraps an array before the filter sees it and would never
50
50
  -- call one an array.
51
- constraint reporting_events_data_flat_check check (not jsonb_path_exists(data, 'strict $.* ? (@.type() == "object" || @.type() == "array")')),
51
+ -- `silent`, so a non-object `data` fails the object check by name rather
52
+ -- than raising 22033 from this one, whichever the planner evaluates first.
53
+ constraint reporting_events_data_flat_check check (not jsonb_path_exists(data, 'strict $.* ? (@.type() == "object" || @.type() == "array")', '{}', true)),
52
54
  -- The keys that carry a person or a secret by name. A guardrail, not a
53
55
  -- proof: src/schema.ts refuses the same list before a row is queued, and
54
56
  -- schema.test.ts pins the two lists equal.
@@ -86,9 +88,6 @@ create table if not exists reporting_tasks (
86
88
  claim_token uuid,
87
89
  last_outcome text,
88
90
  last_error text,
89
- -- Free for a task's own bookkeeping: the analytics rollup keeps its
90
- -- completed-day watermark here.
91
- watermark text,
92
91
  constraint reporting_tasks_task_check check (task ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
93
92
  constraint reporting_tasks_outcome_check check (last_outcome is null or last_outcome in ('ok', 'error', 'skipped')),
94
93
  constraint reporting_tasks_error_check check (last_error is null or length(last_error) <= 512)
@@ -0,0 +1,350 @@
1
+ -- @wtfalch/reporting 0.2.0: the analytics record. Mirrors src/tables.ts.
2
+ --
3
+ -- Three tables (docs/plans/reporting.md D13). `reporting_analytics` holds
4
+ -- raw rows for a window; the two rollups hold counts and nothing that
5
+ -- identifies anybody, which is what lets them outlive the raw rows and be
6
+ -- shown to a customer organisation. Every statement is idempotent; the file
7
+ -- is applied by the host's own migrate script after `reporting-migrations`
8
+ -- copies it in as the next number.
9
+
10
+ -- -------------------------------------------------------------- raw rows
11
+ -- No ip, no user agent, no email, no name, no free text longer than a path.
12
+ -- `user_id` is set only server-side from the session, never from the beacon;
13
+ -- `visitor_id` is the first-party cookie's value when consent gave one.
14
+ -- `path` is the pathname only, normalised to a route pattern before it gets
15
+ -- here (addendum A7), so no identifier or token survives into storage.
16
+ create table if not exists reporting_analytics (
17
+ id bigint generated always as identity primary key,
18
+ occurred_at timestamptz not null,
19
+ received_at timestamptz not null default now(),
20
+ site text not null,
21
+ tenant_id uuid,
22
+ visitor_id text,
23
+ session_id text,
24
+ user_id text,
25
+ name text not null,
26
+ path text not null,
27
+ referrer_host text,
28
+ device text not null,
29
+ country text,
30
+ props jsonb not null default '{}'::jsonb,
31
+ schema_version smallint not null default 1,
32
+ constraint reporting_analytics_site_check check (site ~ '^[a-z][a-z0-9-]{0,63}$'),
33
+ constraint reporting_analytics_name_check check (name ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$'),
34
+ constraint reporting_analytics_path_check check (length(path) between 1 and 512 and position('?' in path) = 0),
35
+ constraint reporting_analytics_referrer_check check (referrer_host is null or length(referrer_host) <= 253),
36
+ constraint reporting_analytics_device_check check (device in ('desktop', 'mobile', 'tablet', 'bot', 'unknown')),
37
+ constraint reporting_analytics_country_check check (country is null or country ~ '^[A-Z]{2}$'),
38
+ constraint reporting_analytics_ids_check check (
39
+ (visitor_id is null or length(visitor_id) between 8 and 64)
40
+ and (session_id is null or length(session_id) between 8 and 64)
41
+ and (user_id is null or length(user_id) between 1 and 256)
42
+ ),
43
+ -- The beacon's clock is a claim; ten minutes either side of arrival is
44
+ -- the most it may say.
45
+ constraint reporting_analytics_clock_check check (abs(extract(epoch from (occurred_at - received_at))) <= 600),
46
+ constraint reporting_analytics_props_object_check check (jsonb_typeof(props) = 'object'),
47
+ constraint reporting_analytics_props_size_check check (octet_length(props::text) <= 4096),
48
+ constraint reporting_analytics_props_flat_check check (not jsonb_path_exists(props, 'strict $.* ? (@.type() == "object" || @.type() == "array")', '{}', true)),
49
+ constraint reporting_analytics_props_keys_check check (
50
+ not (props ?| array['email', 'name', 'display_name', 'displayName', 'password', 'secret', 'token', 'authorization'])
51
+ )
52
+ );
53
+ create index if not exists reporting_analytics_site_time_idx
54
+ on reporting_analytics (site, occurred_at desc, id desc);
55
+ create index if not exists reporting_analytics_tenant_time_idx
56
+ on reporting_analytics (tenant_id, occurred_at desc, id desc) where tenant_id is not null;
57
+ create index if not exists reporting_analytics_user_idx
58
+ on reporting_analytics (user_id) where user_id is not null;
59
+ create index if not exists reporting_analytics_site_name_time_idx
60
+ on reporting_analytics (site, name, occurred_at desc);
61
+ -- The rollups read one UTC day at a time.
62
+ create index if not exists reporting_analytics_day_idx
63
+ on reporting_analytics (((occurred_at at time zone 'UTC')::date), site);
64
+ alter table reporting_analytics set (autovacuum_vacuum_scale_factor = 0.02);
65
+
66
+ -- ------------------------------------------------------------ daily rollup
67
+ -- One row per day, site, grain and dimension value. `grain` names which
68
+ -- dimension the row is grouped by (addendum A4: distinct counts are stored
69
+ -- at every level a reader promises, never summed from finer groups):
70
+ -- site the whole site (every dimension null)
71
+ -- tenant per organisation
72
+ -- name per event name
73
+ -- path page views per normalised route, top 200 a day plus '(other)'
74
+ -- device, country, referrer per dimension, referrers top 50 plus '(other)'
75
+ -- A day's whole set is replaced atomically by reporting_rollup_day, so a
76
+ -- bucket that leaves the top list does not linger. The generated key
77
+ -- columns make the nullable dimensions part of the primary key.
78
+ create table if not exists reporting_analytics_daily (
79
+ day date not null,
80
+ site text not null,
81
+ grain text not null,
82
+ tenant_id uuid,
83
+ name text,
84
+ path text,
85
+ device text,
86
+ country text,
87
+ referrer_host text,
88
+ views integer not null,
89
+ visitors integer not null,
90
+ people integer not null,
91
+ tenant_key text generated always as (coalesce(tenant_id::text, '')) stored,
92
+ name_key text generated always as (coalesce(name, '')) stored,
93
+ path_key text generated always as (coalesce(path, '')) stored,
94
+ device_key text generated always as (coalesce(device, '')) stored,
95
+ country_key text generated always as (coalesce(country, '')) stored,
96
+ referrer_key text generated always as (coalesce(referrer_host, '')) stored,
97
+ constraint reporting_analytics_daily_grain_check check (grain in ('site', 'tenant', 'name', 'path', 'device', 'country', 'referrer')),
98
+ constraint reporting_analytics_daily_pkey primary key (day, site, grain, tenant_key, name_key, path_key, device_key, country_key, referrer_key)
99
+ );
100
+ create index if not exists reporting_analytics_daily_site_grain_idx
101
+ on reporting_analytics_daily (site, grain, day desc);
102
+
103
+ -- ----------------------------------------------------------- weekly rollup
104
+ -- Visitors and people per ISO week (the Monday), and how many of them were
105
+ -- seen in one of the four weeks before, inside the raw window: the one
106
+ -- question the daily table cannot answer.
107
+ create table if not exists reporting_analytics_weekly (
108
+ week date not null,
109
+ site text not null,
110
+ tenant_id uuid,
111
+ visitors integer not null,
112
+ returning_visitors integer not null,
113
+ people integer not null,
114
+ returning_people integer not null,
115
+ tenant_key text generated always as (coalesce(tenant_id::text, '')) stored,
116
+ constraint reporting_analytics_weekly_pkey primary key (week, site, tenant_key)
117
+ );
118
+
119
+ -- The rollups' completed-history watermark (addendum A3), one text value
120
+ -- per task, moved in the same transaction as the aggregate set it vouches
121
+ -- for and fenced by the claim token.
122
+ alter table reporting_tasks add column if not exists watermark text;
123
+
124
+ -- Settings the next release reserved: the raw window, the consent mode and
125
+ -- the signed-in switch, per site through the key's suffix.
126
+ alter table reporting_settings drop constraint if exists reporting_settings_key_check;
127
+ alter table reporting_settings add constraint reporting_settings_key_check
128
+ check (key ~ '^(events|analytics)\.[a-z][a-z0-9_]*$');
129
+
130
+ -- --------------------------------------------------------- reporting_rollup_day
131
+ -- Replaces one UTC day's aggregate set. Definer, because the runtime role
132
+ -- may not write the rollups directly: their contents are derived, and only
133
+ -- this function derives them. Returns the number of rows written.
134
+ create or replace function reporting_rollup_day(d date)
135
+ returns integer
136
+ language plpgsql
137
+ security definer
138
+ set search_path = pg_catalog, public
139
+ as $$
140
+ declare
141
+ n integer;
142
+ lo timestamptz := (d::timestamp at time zone 'UTC');
143
+ hi timestamptz := ((d + 1)::timestamp at time zone 'UTC');
144
+ begin
145
+ if d is null then
146
+ raise exception 'reporting_rollup_day: a day is required';
147
+ end if;
148
+ delete from reporting_analytics_daily where day = d;
149
+ with raw as (
150
+ select site, tenant_id, visitor_id, user_id, name, path, device, country, referrer_host
151
+ from reporting_analytics
152
+ where occurred_at >= lo and occurred_at < hi
153
+ ),
154
+ top_paths as (
155
+ select site, path from (
156
+ select site, path, count(*) as c,
157
+ row_number() over (partition by site order by count(*) desc, path) as rn
158
+ from raw where name = 'page.view' group by site, path
159
+ ) t where rn <= 200
160
+ ),
161
+ top_refs as (
162
+ select site, referrer_host from (
163
+ select site, referrer_host, count(*) as c,
164
+ row_number() over (partition by site order by count(*) desc, referrer_host) as rn
165
+ from raw where referrer_host is not null group by site, referrer_host
166
+ ) t where rn <= 50
167
+ ),
168
+ rows_out as (
169
+ -- site
170
+ select site, 'site' as grain, null::uuid as tenant_id, null::text as name, null::text as path,
171
+ null::text as device, null::text as country, null::text as referrer_host,
172
+ count(*) as views, count(distinct visitor_id) as visitors, count(distinct user_id) as people
173
+ from raw group by site
174
+ union all
175
+ -- tenant
176
+ select site, 'tenant', tenant_id, null, null, null, null, null,
177
+ count(*), count(distinct visitor_id), count(distinct user_id)
178
+ from raw where tenant_id is not null group by site, tenant_id
179
+ union all
180
+ -- name
181
+ select site, 'name', null, name, null, null, null, null,
182
+ count(*), count(distinct visitor_id), count(distinct user_id)
183
+ from raw group by site, name
184
+ union all
185
+ -- path: page views only, top 200 plus (other)
186
+ select r.site, 'path', null, null,
187
+ case when tp.path is null then '(other)' else r.path end,
188
+ null, null, null,
189
+ count(*), count(distinct r.visitor_id), count(distinct r.user_id)
190
+ from raw r left join top_paths tp on tp.site = r.site and tp.path = r.path
191
+ where r.name = 'page.view'
192
+ group by r.site, case when tp.path is null then '(other)' else r.path end
193
+ union all
194
+ -- device
195
+ select site, 'device', null, null, null, device, null, null,
196
+ count(*), count(distinct visitor_id), count(distinct user_id)
197
+ from raw group by site, device
198
+ union all
199
+ -- country
200
+ select site, 'country', null, null, null, null, coalesce(country, 'ZZ'), null,
201
+ count(*), count(distinct visitor_id), count(distinct user_id)
202
+ from raw group by site, coalesce(country, 'ZZ')
203
+ union all
204
+ -- referrer: top 50 plus (other); rows with no referrer are '(direct)'
205
+ select r.site, 'referrer', null, null, null, null, null,
206
+ case when r.referrer_host is null then '(direct)'
207
+ when tr.referrer_host is null then '(other)'
208
+ else r.referrer_host end,
209
+ count(*), count(distinct r.visitor_id), count(distinct r.user_id)
210
+ from raw r left join top_refs tr on tr.site = r.site and tr.referrer_host = r.referrer_host
211
+ group by r.site, case when r.referrer_host is null then '(direct)'
212
+ when tr.referrer_host is null then '(other)'
213
+ else r.referrer_host end
214
+ )
215
+ insert into reporting_analytics_daily
216
+ (day, site, grain, tenant_id, name, path, device, country, referrer_host, views, visitors, people)
217
+ select d, site, grain, tenant_id, name, path, device, country, referrer_host,
218
+ views::integer, visitors::integer, people::integer
219
+ from rows_out;
220
+ get diagnostics n = row_count;
221
+ return n;
222
+ end
223
+ $$;
224
+ revoke all on function reporting_rollup_day(date) from public;
225
+
226
+ -- -------------------------------------------------------- reporting_rollup_week
227
+ -- Replaces one ISO week's rows (w is the Monday). Returning means seen in
228
+ -- any of the four weeks before, which is the raw lookback this rollup
229
+ -- declares; reporting_prune_analytics is told about it.
230
+ create or replace function reporting_rollup_week(w date)
231
+ returns integer
232
+ language plpgsql
233
+ security definer
234
+ set search_path = pg_catalog, public
235
+ as $$
236
+ declare
237
+ n integer;
238
+ lo timestamptz := (w::timestamp at time zone 'UTC');
239
+ hi timestamptz := ((w + 7)::timestamp at time zone 'UTC');
240
+ back timestamptz := ((w - 28)::timestamp at time zone 'UTC');
241
+ begin
242
+ if w is null or extract(isodow from w) <> 1 then
243
+ raise exception 'reporting_rollup_week: a Monday is required';
244
+ end if;
245
+ delete from reporting_analytics_weekly where week = w;
246
+ with this_week as (
247
+ select site, tenant_id, visitor_id, user_id
248
+ from reporting_analytics where occurred_at >= lo and occurred_at < hi
249
+ ),
250
+ earlier as (
251
+ select distinct site, visitor_id, user_id
252
+ from reporting_analytics where occurred_at >= back and occurred_at < lo
253
+ ),
254
+ scopes as (
255
+ select site, null::uuid as tenant_id, visitor_id, user_id from this_week
256
+ union all
257
+ select site, tenant_id, visitor_id, user_id from this_week where tenant_id is not null
258
+ )
259
+ insert into reporting_analytics_weekly
260
+ (week, site, tenant_id, visitors, returning_visitors, people, returning_people)
261
+ select w, s.site, s.tenant_id,
262
+ count(distinct s.visitor_id),
263
+ count(distinct s.visitor_id) filter (where exists (
264
+ select 1 from earlier e where e.site = s.site and e.visitor_id = s.visitor_id)),
265
+ count(distinct s.user_id),
266
+ count(distinct s.user_id) filter (where exists (
267
+ select 1 from earlier e where e.site = s.site and e.user_id = s.user_id))
268
+ from scopes s
269
+ group by s.site, s.tenant_id;
270
+ get diagnostics n = row_count;
271
+ return n;
272
+ end
273
+ $$;
274
+ revoke all on function reporting_rollup_week(date) from public;
275
+
276
+ -- ------------------------------------------------------ reporting_prune_analytics
277
+ -- The one way raw rows leave by age. `safe_before` is the earliest day the
278
+ -- rollups still need (the daily watermark, and the weekly watermark less
279
+ -- its four-week lookback, whichever is earlier): a cutoff at or past it is
280
+ -- refused, so pruning never outruns aggregation (addendum A3).
281
+ create or replace function reporting_prune_analytics(retention interval, batch integer, safe_before date)
282
+ returns integer
283
+ language plpgsql
284
+ security definer
285
+ set search_path = pg_catalog, public
286
+ as $$
287
+ declare
288
+ w interval := greatest(interval '7 days', least(coalesce(retention, interval '90 days'), interval '400 days'));
289
+ b integer := greatest(1, least(coalesce(batch, 5000), 5000));
290
+ cutoff timestamptz := now() - w;
291
+ n integer;
292
+ begin
293
+ if safe_before is null then
294
+ raise exception 'reporting_prune_analytics: the rollups have not completed a day yet; nothing is safe to prune';
295
+ end if;
296
+ if cutoff >= (safe_before::timestamp at time zone 'UTC') then
297
+ raise exception 'reporting_prune_analytics: cutoff % is not before the rollups'' watermark %', cutoff, safe_before;
298
+ end if;
299
+ with victims as (
300
+ select id from reporting_analytics
301
+ where occurred_at < cutoff
302
+ order by occurred_at, id
303
+ limit b
304
+ )
305
+ delete from reporting_analytics a using victims v where a.id = v.id;
306
+ get diagnostics n = row_count;
307
+ return n;
308
+ end
309
+ $$;
310
+ revoke all on function reporting_prune_analytics(interval, integer, date) from public;
311
+
312
+ -- -------------------------------------------------------- reporting_erase_person
313
+ -- The subject's raw rows go; the rollups hold no identifier and stay. Called
314
+ -- by the host inside its erasure transaction. Returns the rows deleted.
315
+ create or replace function reporting_erase_person(subject text)
316
+ returns integer
317
+ language plpgsql
318
+ security definer
319
+ set search_path = pg_catalog, public
320
+ as $$
321
+ declare
322
+ n integer;
323
+ begin
324
+ if subject is null or length(subject) = 0 then
325
+ raise exception 'reporting_erase_person: a subject id is required';
326
+ end if;
327
+ delete from reporting_analytics where user_id = subject;
328
+ get diagnostics n = row_count;
329
+ return n;
330
+ end
331
+ $$;
332
+ revoke all on function reporting_erase_person(text) from public;
333
+
334
+ -- The runtime role inserts and reads raw rows, reads the rollups, and
335
+ -- changes either only through the functions above.
336
+ do $$
337
+ declare
338
+ rt text := current_database() || '_rt';
339
+ begin
340
+ if exists (select 1 from pg_roles where rolname = rt) then
341
+ execute format('revoke update, delete, truncate on reporting_analytics from %I', rt);
342
+ execute format('revoke insert, update, delete, truncate on reporting_analytics_daily from %I', rt);
343
+ execute format('revoke insert, update, delete, truncate on reporting_analytics_weekly from %I', rt);
344
+ execute format('grant execute on function reporting_rollup_day(date) to %I', rt);
345
+ execute format('grant execute on function reporting_rollup_week(date) to %I', rt);
346
+ execute format('grant execute on function reporting_prune_analytics(interval, integer, date) to %I', rt);
347
+ execute format('grant execute on function reporting_erase_person(text) to %I', rt);
348
+ end if;
349
+ end
350
+ $$;
@@ -1,3 +1,5 @@
1
+ import { type CollectorOptions } from '../analytics/collector.js';
2
+ import { deviceOf } from '../analytics/schema.js';
1
3
  import type { Reporting } from '../types.js';
2
4
  /**
3
5
  * The Next bindings: everything in the package that knows what a request is.
@@ -38,3 +40,17 @@ export declare const deferWithAfter: (fn: () => Promise<void>) => void;
38
40
  export declare function registerShutdownFlush(reporting: Reporting, opts?: {
39
41
  deadlineMs?: number;
40
42
  }): void;
43
+ /**
44
+ * The collector as a route handler: mount as `POST` and `OPTIONS` of a public
45
+ * route (`// authz: public`). Always 204. `getUserId` reads the host's
46
+ * session for same-origin beacons; a beacon from an allowed foreign origin
47
+ * never reaches it.
48
+ */
49
+ export declare function collectHandler(options: Omit<CollectorOptions, 'log' | 'site'> & {
50
+ readonly reporting: Reporting;
51
+ }): (request: Request) => Promise<Response>;
52
+ /** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
53
+ export declare function trackContext(): Promise<{
54
+ device: ReturnType<typeof deviceOf>;
55
+ country: string | null;
56
+ }>;
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
2
2
  import { headers } from 'next/headers';
3
3
  import { unstable_rethrow } from 'next/navigation';
4
4
  import { after, connection } from 'next/server';
5
+ import { createCollector } from '../analytics/collector.js';
6
+ import { countryOf, deviceOf } from '../analytics/schema.js';
5
7
  /**
6
8
  * The Next bindings: everything in the package that knows what a request is.
7
9
  * Peer on `next`; a host without Next never imports this entry.
@@ -19,6 +21,8 @@ export function healthHandler(housekeeping) {
19
21
  return Response.json({ ok: true, ts: new Date().toISOString() });
20
22
  };
21
23
  }
24
+ /** `<16 hex>-<colo>`, as Cloudflare emits it. */
25
+ const RAY_PATTERN = /^[0-9a-f]{16}-[A-Z0-9]{3,6}$/;
22
26
  /**
23
27
  * Read once where a request enters (a Server Action, a route handler, a
24
28
  * page's data component) and handed down. Outside a request scope it returns
@@ -30,8 +34,12 @@ export async function requestContext() {
30
34
  try {
31
35
  const h = await headers();
32
36
  const country = h.get('cf-ipcountry');
37
+ // A ray id is Cloudflare's or it is nothing: a request that reached the
38
+ // origin some other way (the Docker network, a preview host) could
39
+ // otherwise choose the id stamped on the audit rows of this request.
40
+ const ray = h.get('cf-ray');
33
41
  return {
34
- id: (h.get('cf-ray') ?? randomUUID()).slice(0, 128),
42
+ id: ray && RAY_PATTERN.test(ray) ? ray : randomUUID(),
35
43
  ip: h.get('cf-connecting-ip'),
36
44
  userAgent: h.get('user-agent'),
37
45
  country: country && /^[A-Z]{2}$/.test(country) ? country : null,
@@ -65,3 +73,25 @@ export function registerShutdownFlush(reporting, opts = {}) {
65
73
  void reporting.flush({ deadlineMs: opts.deadlineMs ?? 2000 });
66
74
  });
67
75
  }
76
+ /**
77
+ * The collector as a route handler: mount as `POST` and `OPTIONS` of a public
78
+ * route (`// authz: public`). Always 204. `getUserId` reads the host's
79
+ * session for same-origin beacons; a beacon from an allowed foreign origin
80
+ * never reaches it.
81
+ */
82
+ export function collectHandler(options) {
83
+ const { reporting, ...rest } = options;
84
+ const collector = createCollector({ ...rest, log: reporting.log, site: reporting.site });
85
+ return (request) => collector.handle(request);
86
+ }
87
+ /** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
88
+ export async function trackContext() {
89
+ try {
90
+ const h = await headers();
91
+ return { device: deviceOf(h.get('user-agent')), country: countryOf(h.get('cf-ipcountry')) };
92
+ }
93
+ catch (error) {
94
+ unstable_rethrow(error);
95
+ return { device: 'unknown', country: null };
96
+ }
97
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Two server-renderable pictures for the readers' pages, inline SVG with no
4
+ * dependency: a sparkline over a series and a bar list over buckets. Colours
5
+ * come from `currentColor` and CSS variables the host sets, so the host's
6
+ * theme applies.
7
+ */
8
+ export declare function Sparkline(props: {
9
+ readonly points: readonly {
10
+ readonly day: string;
11
+ readonly value: number;
12
+ }[];
13
+ readonly width?: number;
14
+ readonly height?: number;
15
+ readonly label?: string;
16
+ }): import("react").JSX.Element;
17
+ export declare function BarList(props: {
18
+ readonly items: readonly {
19
+ readonly key: string;
20
+ readonly value: number;
21
+ readonly hint?: ReactNode;
22
+ }[];
23
+ readonly label?: string;
24
+ }): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Two server-renderable pictures for the readers' pages, inline SVG with no
4
+ * dependency: a sparkline over a series and a bar list over buckets. Colours
5
+ * come from `currentColor` and CSS variables the host sets, so the host's
6
+ * theme applies.
7
+ */
8
+ export function Sparkline(props) {
9
+ const w = props.width ?? 320;
10
+ const h = props.height ?? 64;
11
+ const values = props.points.map((p) => p.value);
12
+ const max = Math.max(1, ...values);
13
+ const n = Math.max(1, values.length - 1);
14
+ const coords = values.map((v, i) => [(i / n) * (w - 2) + 1, h - 1 - (v / max) * (h - 2)]);
15
+ const line = coords
16
+ .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`)
17
+ .join(' ');
18
+ const area = coords.length > 0 ? `${line} L${(w - 1).toFixed(1)} ${h - 1} L1 ${h - 1} Z` : '';
19
+ const last = coords.at(-1);
20
+ return (_jsxs("svg", { viewBox: `0 0 ${w} ${h}`, width: "100%", height: h, role: "img", "aria-label": props.label ?? `${values.length} days, up to ${max}`, preserveAspectRatio: "none", style: { display: 'block', maxWidth: '100%' }, children: [_jsx("title", { children: props.label ?? `${values.length} days, up to ${max}` }), area ? _jsx("path", { d: area, fill: "currentColor", opacity: "0.12" }) : null, line ? _jsx("path", { d: line, fill: "none", stroke: "currentColor", strokeWidth: "1.5" }) : null, last ? _jsx("circle", { cx: last[0], cy: last[1], r: "2.5", fill: "currentColor" }) : null] }));
21
+ }
22
+ export function BarList(props) {
23
+ const max = Math.max(1, ...props.items.map((i) => i.value));
24
+ return (_jsx("ol", { "data-reporting-bars": "", "aria-label": props.label, style: { listStyle: 'none', margin: 0, padding: 0 }, children: props.items.map((item) => (_jsxs("li", { style: {
25
+ display: 'grid',
26
+ gridTemplateColumns: '1fr auto',
27
+ gap: '0.5rem',
28
+ alignItems: 'center',
29
+ padding: '0.25rem 0',
30
+ }, children: [_jsxs("div", { style: { position: 'relative', minWidth: 0 }, children: [_jsx("div", { "aria-hidden": "true", style: {
31
+ position: 'absolute',
32
+ inset: 0,
33
+ width: `${(item.value / max) * 100}%`,
34
+ background: 'currentColor',
35
+ opacity: 0.1,
36
+ borderRadius: 2,
37
+ } }), _jsxs("span", { style: {
38
+ position: 'relative',
39
+ overflow: 'hidden',
40
+ textOverflow: 'ellipsis',
41
+ whiteSpace: 'nowrap',
42
+ display: 'block',
43
+ padding: '0 0.375rem',
44
+ }, children: [item.key, item.hint ? (_jsx("span", { style: { opacity: 0.6, marginLeft: '0.5rem' }, children: item.hint })) : null] })] }), _jsx("span", { style: { fontVariantNumeric: 'tabular-nums' }, children: item.value.toLocaleString() })] }, item.key))) }));
45
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ConsentMode } from '../browser/index.js';
3
+ export declare function ReportingProvider(props: {
4
+ readonly collector: string;
5
+ readonly site: string;
6
+ readonly consent?: ConsentMode;
7
+ readonly children: ReactNode;
8
+ }): import("react").JSX.Element;
9
+ /** `track(name, props)` from a client component; a no-op before the beacon mounts. */
10
+ export declare function useTrack(): (name: string, props?: Record<string, string | number | boolean | null>) => void;
11
+ /**
12
+ * The consent question, shown only under `consented` and only until it is
13
+ * answered. The host supplies the words; the control supplies the two
14
+ * buttons and remembers the answer through the beacon's cookie.
15
+ */
16
+ export declare function ConsentControl(props: {
17
+ readonly children: ReactNode;
18
+ readonly acceptLabel?: string;
19
+ readonly declineLabel?: string;
20
+ readonly className?: string;
21
+ }): import("react").JSX.Element | null;