@wtfalch/reporting 0.1.2 → 0.3.0

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.
Files changed (51) hide show
  1. package/README.md +56 -0
  2. package/dist/analytics/collector.d.ts +40 -0
  3. package/dist/analytics/collector.js +146 -0
  4. package/dist/analytics/reader.d.ts +63 -0
  5. package/dist/analytics/reader.js +89 -0
  6. package/dist/analytics/schema.d.ts +59 -0
  7. package/dist/analytics/schema.js +126 -0
  8. package/dist/analytics/tasks.d.ts +32 -0
  9. package/dist/analytics/tasks.js +199 -0
  10. package/dist/analytics/write.d.ts +19 -0
  11. package/dist/analytics/write.js +20 -0
  12. package/dist/browser/index.d.ts +53 -0
  13. package/dist/browser/index.js +330 -0
  14. package/dist/errors/capture.d.ts +52 -0
  15. package/dist/errors/capture.js +187 -0
  16. package/dist/errors/fingerprint.d.ts +36 -0
  17. package/dist/errors/fingerprint.js +211 -0
  18. package/dist/errors/reader.d.ts +19 -0
  19. package/dist/errors/reader.js +66 -0
  20. package/dist/errors/redact.d.ts +46 -0
  21. package/dist/errors/redact.js +93 -0
  22. package/dist/errors/tasks.d.ts +16 -0
  23. package/dist/errors/tasks.js +50 -0
  24. package/dist/fake/index.d.ts +5 -1
  25. package/dist/fake/index.js +28 -0
  26. package/dist/housekeeping/index.d.ts +5 -0
  27. package/dist/housekeeping/index.js +18 -6
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +61 -5
  30. package/dist/ingest-limit.d.ts +22 -0
  31. package/dist/ingest-limit.js +39 -0
  32. package/dist/migrations/0002_analytics.sql +350 -0
  33. package/dist/migrations/0003_errors.sql +118 -0
  34. package/dist/next/client-errors.d.ts +46 -0
  35. package/dist/next/client-errors.js +165 -0
  36. package/dist/next/index.d.ts +55 -0
  37. package/dist/next/index.js +72 -0
  38. package/dist/react/charts.d.ts +24 -0
  39. package/dist/react/charts.js +45 -0
  40. package/dist/react/errors.d.ts +12 -0
  41. package/dist/react/errors.js +17 -0
  42. package/dist/react/index.d.ts +21 -0
  43. package/dist/react/index.js +52 -0
  44. package/dist/schema.d.ts +8 -0
  45. package/dist/schema.js +8 -0
  46. package/dist/settings.d.ts +1 -0
  47. package/dist/settings.js +15 -8
  48. package/dist/tables.d.ts +2287 -253
  49. package/dist/tables.js +111 -1
  50. package/dist/types.d.ts +70 -2
  51. package/package.json +19 -1
@@ -1,4 +1,7 @@
1
+ import { type CollectorOptions } from '../analytics/collector.js';
2
+ import { deviceOf } from '../analytics/schema.js';
1
3
  import type { Reporting } from '../types.js';
4
+ import { type ClientErrorCollectorOptions } from './client-errors.js';
2
5
  /**
3
6
  * The Next bindings: everything in the package that knows what a request is.
4
7
  * Peer on `next`; a host without Next never imports this entry.
@@ -38,3 +41,55 @@ export declare const deferWithAfter: (fn: () => Promise<void>) => void;
38
41
  export declare function registerShutdownFlush(reporting: Reporting, opts?: {
39
42
  deadlineMs?: number;
40
43
  }): void;
44
+ /**
45
+ * The collector as a route handler: mount as `POST` and `OPTIONS` of a public
46
+ * route (`// authz: public`). Always 204. `getUserId` reads the host's
47
+ * session for same-origin beacons; a beacon from an allowed foreign origin
48
+ * never reaches it.
49
+ */
50
+ export declare function collectHandler(options: Omit<CollectorOptions, 'log' | 'site'> & {
51
+ readonly reporting: Reporting;
52
+ }): (request: Request) => Promise<Response>;
53
+ /**
54
+ * `onRequestError` from Next's instrumentation hook
55
+ * (`next/dist/server/instrumentation/types.d.ts`, `InstrumentationOnRequestError`).
56
+ * Declared locally: no public `next` subpath exports the type, and this is
57
+ * the exact shape the compiled server calls it with
58
+ * (`base-server.js`'s `instrumentationOnRequestError`).
59
+ */
60
+ export type NextOnRequestError = (error: unknown, request: Readonly<{
61
+ path: string;
62
+ method: string;
63
+ headers: NodeJS.Dict<string | string[]>;
64
+ }>, context: Readonly<{
65
+ routerKind: string;
66
+ routePath: string;
67
+ routeType: string;
68
+ }>) => void | Promise<void>;
69
+ /**
70
+ * Binds `reporting.captureError` to Next's `onRequestError`
71
+ * (docs/plans/errors.md, "Surface"). `runtime` is `edge` exactly when
72
+ * `NEXT_RUNTIME` says so — the same signal Next's own compiled server
73
+ * branches on (e.g. `dist/server/app-render/action-handler.js`), because
74
+ * the error context Next hands this hook carries no runtime field of its
75
+ * own. `requestId` is derived the same way `requestContext()` derives one:
76
+ * Cloudflare's `cf-ray`, else a fresh uuid.
77
+ *
78
+ * Never throws: Next calls this while already handling an error, and a
79
+ * reporter that throws back is the one failure worse than not reporting —
80
+ * the same rule `captureError` itself already keeps (errors/capture.ts).
81
+ * This catch is defence in depth for a `reporting` that violates it.
82
+ */
83
+ export declare function errorHandler(reporting: Reporting): NextOnRequestError;
84
+ /**
85
+ * The public ingest route for browser-reported errors: mount as `POST`
86
+ * (docs/plans/errors.md, "Surface"; `// authz: public`). See
87
+ * `createClientErrorCollector` (./client-errors.js) for the caps, the
88
+ * origin allowance and why identity is never the body's.
89
+ */
90
+ export declare function clientErrorHandler(options: ClientErrorCollectorOptions): (request: Request) => Promise<Response>;
91
+ /** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
92
+ export declare function trackContext(): Promise<{
93
+ device: ReturnType<typeof deviceOf>;
94
+ country: string | null;
95
+ }>;
@@ -2,6 +2,9 @@ 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';
7
+ import { createClientErrorCollector } from './client-errors.js';
5
8
  /**
6
9
  * The Next bindings: everything in the package that knows what a request is.
7
10
  * Peer on `next`; a host without Next never imports this entry.
@@ -71,3 +74,72 @@ export function registerShutdownFlush(reporting, opts = {}) {
71
74
  void reporting.flush({ deadlineMs: opts.deadlineMs ?? 2000 });
72
75
  });
73
76
  }
77
+ /**
78
+ * The collector as a route handler: mount as `POST` and `OPTIONS` of a public
79
+ * route (`// authz: public`). Always 204. `getUserId` reads the host's
80
+ * session for same-origin beacons; a beacon from an allowed foreign origin
81
+ * never reaches it.
82
+ */
83
+ export function collectHandler(options) {
84
+ const { reporting, ...rest } = options;
85
+ const collector = createCollector({ ...rest, log: reporting.log, site: reporting.site });
86
+ return (request) => collector.handle(request);
87
+ }
88
+ function headerValue(dict, name) {
89
+ const lower = name.toLowerCase();
90
+ for (const key of Object.keys(dict)) {
91
+ if (key.toLowerCase() !== lower)
92
+ continue;
93
+ const value = dict[key];
94
+ return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
95
+ }
96
+ return null;
97
+ }
98
+ /**
99
+ * Binds `reporting.captureError` to Next's `onRequestError`
100
+ * (docs/plans/errors.md, "Surface"). `runtime` is `edge` exactly when
101
+ * `NEXT_RUNTIME` says so — the same signal Next's own compiled server
102
+ * branches on (e.g. `dist/server/app-render/action-handler.js`), because
103
+ * the error context Next hands this hook carries no runtime field of its
104
+ * own. `requestId` is derived the same way `requestContext()` derives one:
105
+ * Cloudflare's `cf-ray`, else a fresh uuid.
106
+ *
107
+ * Never throws: Next calls this while already handling an error, and a
108
+ * reporter that throws back is the one failure worse than not reporting —
109
+ * the same rule `captureError` itself already keeps (errors/capture.ts).
110
+ * This catch is defence in depth for a `reporting` that violates it.
111
+ */
112
+ export function errorHandler(reporting) {
113
+ return (error, request) => {
114
+ try {
115
+ const runtime = process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'server';
116
+ const ray = headerValue(request.headers, 'cf-ray');
117
+ const requestId = ray && RAY_PATTERN.test(ray) ? ray : randomUUID();
118
+ reporting.captureError(error, { runtime, requestId });
119
+ }
120
+ catch {
121
+ // See the doc comment above: this must never throw back into Next.
122
+ }
123
+ };
124
+ }
125
+ /**
126
+ * The public ingest route for browser-reported errors: mount as `POST`
127
+ * (docs/plans/errors.md, "Surface"; `// authz: public`). See
128
+ * `createClientErrorCollector` (./client-errors.js) for the caps, the
129
+ * origin allowance and why identity is never the body's.
130
+ */
131
+ export function clientErrorHandler(options) {
132
+ const collector = createClientErrorCollector(options);
133
+ return (request) => collector.handle(request);
134
+ }
135
+ /** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
136
+ export async function trackContext() {
137
+ try {
138
+ const h = await headers();
139
+ return { device: deviceOf(h.get('user-agent')), country: countryOf(h.get('cf-ipcountry')) };
140
+ }
141
+ catch (error) {
142
+ unstable_rethrow(error);
143
+ return { device: 'unknown', country: null };
144
+ }
145
+ }
@@ -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,12 @@
1
+ import type { ReportingErrorRow } from '../tables.js';
2
+ /**
3
+ * The operator's error list: a plain table over the rows `errorsPage()`
4
+ * already returned, newest last-seen first — the same props-in split as
5
+ * `charts.tsx`, no fetching and no client state. The stack and the rest of
6
+ * one group's detail belong in a detail view, not here; this table only ever
7
+ * renders the group's summary columns.
8
+ */
9
+ export declare function ErrorsTable(props: {
10
+ readonly rows: readonly ReportingErrorRow[];
11
+ readonly label?: string;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The operator's error list: a plain table over the rows `errorsPage()`
4
+ * already returned, newest last-seen first — the same props-in split as
5
+ * `charts.tsx`, no fetching and no client state. The stack and the rest of
6
+ * one group's detail belong in a detail view, not here; this table only ever
7
+ * renders the group's summary columns.
8
+ */
9
+ export function ErrorsTable(props) {
10
+ if (props.rows.length === 0) {
11
+ return _jsx("p", { "data-reporting-errors-empty": "", children: "No errors captured. All clear." });
12
+ }
13
+ return (_jsxs("table", { "data-reporting-errors": "", "aria-label": props.label ?? 'Errors, newest last seen first', children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Kind" }), _jsx("th", { scope: "col", children: "Message" }), _jsx("th", { scope: "col", children: "Occurrences" }), _jsx("th", { scope: "col", children: "First seen" }), _jsx("th", { scope: "col", children: "Last seen" }), _jsx("th", { scope: "col", children: "Runtime" }), _jsx("th", { scope: "col", children: "Release" }), _jsx("th", { scope: "col", children: "State" })] }) }), _jsx("tbody", { children: props.rows.map((row) => (_jsxs("tr", { children: [_jsx("td", { children: row.kind }), _jsx("td", { style: { maxWidth: '32rem', overflowWrap: 'anywhere', wordBreak: 'break-word' }, title: row.message, children: row.message }), _jsx("td", { style: { fontVariantNumeric: 'tabular-nums' }, children: String(row.occurrences) }), _jsx("td", { children: formatTimestamp(row.firstSeenAt) }), _jsx("td", { children: formatTimestamp(row.lastSeenAt) }), _jsx("td", { children: row.runtime }), _jsx("td", { children: row.release ?? '—' }), _jsx("td", { children: row.state })] }, row.fingerprint))) })] }));
14
+ }
15
+ function formatTimestamp(value) {
16
+ return `${value.toISOString().slice(0, 19).replace('T', ' ')} UTC`;
17
+ }
@@ -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/schema.d.ts CHANGED
@@ -9,9 +9,15 @@ export declare const LEVELS: readonly ["info", "warn", "error", "alert"];
9
9
  export type Level = (typeof LEVELS)[number];
10
10
  export declare const ACTOR_CLASSES: readonly ["human", "api_key", "agent", "service"];
11
11
  export type ActorClass = (typeof ACTOR_CLASSES)[number];
12
+ export declare const ERROR_STATES: readonly ["open", "resolved", "ignored"];
13
+ export type ErrorState = (typeof ERROR_STATES)[number];
14
+ export declare const ERROR_RUNTIMES: readonly ["server", "edge", "browser"];
15
+ export type ErrorRuntime = (typeof ERROR_RUNTIMES)[number];
12
16
  /** `namespace.name`: one dot, lower case, digits and underscores. */
13
17
  export declare const KIND_PATTERN: RegExp;
14
18
  export declare const SITE_PATTERN: RegExp;
19
+ /** sha256 of the kind and the top normalised frames, truncated to 32 hex, lower case. */
20
+ export declare const FINGERPRINT_PATTERN: RegExp;
15
21
  /**
16
22
  * Keys a `data` object may not carry, because each names a person or a
17
23
  * secret. A guardrail against the accidental case, not proof of absence: a
@@ -27,6 +33,7 @@ export declare const LIMITS: {
27
33
  readonly targetId: 256;
28
34
  readonly dataBytes: 16384;
29
35
  readonly site: 64;
36
+ readonly stack: 16384;
30
37
  };
31
38
  /** Flat, bounded, banned keys refused, numbers finite. */
32
39
  export declare const flatDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
@@ -83,3 +90,4 @@ export declare const eventInputSchema: z.ZodObject<{
83
90
  export type EventInput = z.input<typeof eventInputSchema>;
84
91
  export type ValidEvent = z.output<typeof eventInputSchema>;
85
92
  export declare const siteSchema: z.ZodString;
93
+ export declare const fingerprintSchema: z.ZodString;
package/dist/schema.js CHANGED
@@ -7,9 +7,13 @@ import { z } from 'zod';
7
7
  */
8
8
  export const LEVELS = ['info', 'warn', 'error', 'alert'];
9
9
  export const ACTOR_CLASSES = ['human', 'api_key', 'agent', 'service'];
10
+ export const ERROR_STATES = ['open', 'resolved', 'ignored'];
11
+ export const ERROR_RUNTIMES = ['server', 'edge', 'browser'];
10
12
  /** `namespace.name`: one dot, lower case, digits and underscores. */
11
13
  export const KIND_PATTERN = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
12
14
  export const SITE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
15
+ /** sha256 of the kind and the top normalised frames, truncated to 32 hex, lower case. */
16
+ export const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
13
17
  /**
14
18
  * Keys a `data` object may not carry, because each names a person or a
15
19
  * secret. A guardrail against the accidental case, not proof of absence: a
@@ -34,6 +38,7 @@ export const LIMITS = {
34
38
  targetId: 256,
35
39
  dataBytes: 16384,
36
40
  site: 64,
41
+ stack: 16384,
37
42
  };
38
43
  const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
39
44
  /** Flat, bounded, banned keys refused, numbers finite. */
@@ -85,3 +90,6 @@ export const eventInputSchema = z.strictObject({
85
90
  export const siteSchema = z
86
91
  .string()
87
92
  .regex(SITE_PATTERN, 'a site id is lower case, digits and dashes');
93
+ export const fingerprintSchema = z
94
+ .string()
95
+ .regex(FINGERPRINT_PATTERN, 'a fingerprint is 32 lower-case hex characters');
@@ -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
- schema: z.number().int().min(RETENTION_BOUNDS.min).max(RETENTION_BOUNDS.max),
14
- default: 30,
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(`reporting.settings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`);
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) => {