@wtfalch/reporting 0.1.2 → 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/README.md +56 -0
- package/dist/analytics/collector.d.ts +40 -0
- package/dist/analytics/collector.js +170 -0
- package/dist/analytics/reader.d.ts +63 -0
- package/dist/analytics/reader.js +89 -0
- package/dist/analytics/schema.d.ts +59 -0
- package/dist/analytics/schema.js +126 -0
- package/dist/analytics/tasks.d.ts +32 -0
- package/dist/analytics/tasks.js +199 -0
- package/dist/analytics/write.d.ts +19 -0
- package/dist/analytics/write.js +20 -0
- package/dist/browser/index.d.ts +43 -0
- package/dist/browser/index.js +203 -0
- package/dist/fake/index.d.ts +5 -1
- package/dist/fake/index.js +16 -0
- package/dist/housekeeping/index.d.ts +5 -0
- package/dist/housekeeping/index.js +18 -6
- package/dist/index.d.ts +5 -1
- package/dist/index.js +43 -1
- package/dist/migrations/0002_analytics.sql +350 -0
- package/dist/next/index.d.ts +16 -0
- package/dist/next/index.js +24 -0
- package/dist/react/charts.d.ts +24 -0
- package/dist/react/charts.js +45 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +52 -0
- package/dist/settings.d.ts +1 -0
- package/dist/settings.js +15 -8
- package/dist/tables.d.ts +1734 -226
- package/dist/tables.js +87 -1
- package/dist/types.d.ts +37 -0
- package/package.json +15 -1
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 } 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';
|
|
@@ -75,6 +81,42 @@ export function createReporting(options) {
|
|
|
75
81
|
return change;
|
|
76
82
|
},
|
|
77
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
|
+
},
|
|
78
120
|
stats: () => writer.stats(),
|
|
79
121
|
};
|
|
80
122
|
return reporting;
|
|
@@ -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
|
+
$$;
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/next/index.js
CHANGED
|
@@ -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.
|
|
@@ -71,3 +73,25 @@ export function registerShutdownFlush(reporting, opts = {}) {
|
|
|
71
73
|
void reporting.flush({ deadlineMs: opts.deadlineMs ?? 2000 });
|
|
72
74
|
});
|
|
73
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;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { createBeacon, } from '../browser/index.js';
|
|
5
|
+
const ReportingContext = createContext({
|
|
6
|
+
beacon: null,
|
|
7
|
+
answer: null,
|
|
8
|
+
mode: 'none',
|
|
9
|
+
accept: () => undefined,
|
|
10
|
+
decline: () => undefined,
|
|
11
|
+
});
|
|
12
|
+
export function ReportingProvider(props) {
|
|
13
|
+
const [beacon, setBeacon] = useState(null);
|
|
14
|
+
const [answer, setAnswer] = useState(null);
|
|
15
|
+
const mode = props.consent ?? 'consented';
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const b = createBeacon({ collector: props.collector, site: props.site, consent: mode });
|
|
18
|
+
setBeacon(b);
|
|
19
|
+
setAnswer(b.answer());
|
|
20
|
+
return () => b.destroy();
|
|
21
|
+
}, [props.collector, props.site, mode]);
|
|
22
|
+
const value = useMemo(() => ({
|
|
23
|
+
beacon,
|
|
24
|
+
answer,
|
|
25
|
+
mode,
|
|
26
|
+
accept() {
|
|
27
|
+
beacon?.accept();
|
|
28
|
+
setAnswer(beacon?.answer() ?? null);
|
|
29
|
+
},
|
|
30
|
+
decline() {
|
|
31
|
+
beacon?.decline();
|
|
32
|
+
setAnswer(beacon?.answer() ?? null);
|
|
33
|
+
},
|
|
34
|
+
}), [beacon, answer, mode]);
|
|
35
|
+
return _jsx(ReportingContext.Provider, { value: value, children: props.children });
|
|
36
|
+
}
|
|
37
|
+
/** `track(name, props)` from a client component; a no-op before the beacon mounts. */
|
|
38
|
+
export function useTrack() {
|
|
39
|
+
const { beacon } = useContext(ReportingContext);
|
|
40
|
+
return (name, props) => beacon?.track(name, props);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The consent question, shown only under `consented` and only until it is
|
|
44
|
+
* answered. The host supplies the words; the control supplies the two
|
|
45
|
+
* buttons and remembers the answer through the beacon's cookie.
|
|
46
|
+
*/
|
|
47
|
+
export function ConsentControl(props) {
|
|
48
|
+
const { answer, mode, accept, decline, beacon } = useContext(ReportingContext);
|
|
49
|
+
if (mode !== 'consented' || answer !== null || !beacon)
|
|
50
|
+
return null;
|
|
51
|
+
return (_jsxs("section", { "data-reporting-consent": "", className: props.className, "aria-label": "Analytics consent", children: [props.children, _jsxs("div", { "data-reporting-consent-actions": "", children: [_jsx("button", { type: "button", "data-reporting-consent-decline": "", onClick: decline, children: props.declineLabel ?? 'No thanks' }), _jsx("button", { type: "button", "data-reporting-consent-accept": "", onClick: accept, children: props.acceptLabel ?? 'Allow' })] })] }));
|
|
52
|
+
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const RETENTION_BOUNDS: {
|
|
|
9
9
|
readonly min: 7;
|
|
10
10
|
readonly max: 400;
|
|
11
11
|
};
|
|
12
|
+
export declare const CONSENT_MODES: readonly ["none", "consented", "always"];
|
|
12
13
|
export declare const DEFAULT_SETTINGS: Settings;
|
|
13
14
|
export declare function getSettings(db: Db): Promise<Settings>;
|
|
14
15
|
/** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
|
package/dist/settings.js
CHANGED
|
@@ -8,15 +8,20 @@ import { reportingSettings } from './tables.js';
|
|
|
8
8
|
* do is shorten history to a week.
|
|
9
9
|
*/
|
|
10
10
|
export const RETENTION_BOUNDS = { min: 7, max: 400 };
|
|
11
|
+
export const CONSENT_MODES = ['none', 'consented', 'always'];
|
|
12
|
+
const retention = z.number().int().min(RETENTION_BOUNDS.min).max(RETENTION_BOUNDS.max);
|
|
11
13
|
const DEFS = {
|
|
12
|
-
'events.retention_days': {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
14
|
+
'events.retention_days': { schema: retention, default: 30 },
|
|
15
|
+
// Ninety, not thirty: behaviour is watched over years through the rollups,
|
|
16
|
+
// and a dimension they lack is a recompute over this window (D13).
|
|
17
|
+
'analytics.retention_days': { schema: retention, default: 90 },
|
|
18
|
+
// Cookieless until the person says otherwise (D15).
|
|
19
|
+
'analytics.consent': { schema: z.enum(CONSENT_MODES), default: 'consented' },
|
|
20
|
+
// A signed-in person's rows carry their id, as the product's own record of
|
|
21
|
+
// serving them; one line to change if advice changes (D15).
|
|
22
|
+
'analytics.identify_signed_in': { schema: z.boolean(), default: true },
|
|
16
23
|
};
|
|
17
|
-
export const DEFAULT_SETTINGS = Object.freeze(
|
|
18
|
-
'events.retention_days': DEFS['events.retention_days'].default,
|
|
19
|
-
});
|
|
24
|
+
export const DEFAULT_SETTINGS = Object.freeze(Object.fromEntries(Object.entries(DEFS).map(([k, d]) => [k, d.default])));
|
|
20
25
|
function fromRows(rows) {
|
|
21
26
|
const out = { ...DEFAULT_SETTINGS };
|
|
22
27
|
for (const row of rows) {
|
|
@@ -44,7 +49,9 @@ export async function setSettings(db, patch, by) {
|
|
|
44
49
|
throw new Error(`reporting.settings: unknown key "${key}"`);
|
|
45
50
|
const parsed = def.schema.safeParse(value);
|
|
46
51
|
if (!parsed.success) {
|
|
47
|
-
throw new Error(
|
|
52
|
+
throw new Error(key.endsWith('retention_days')
|
|
53
|
+
? `reporting.settings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`
|
|
54
|
+
: `reporting.settings: ${key} is not a valid value`);
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
return db.transaction(async (tx) => {
|