@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.
- package/README.md +56 -0
- package/dist/analytics/collector.d.ts +40 -0
- package/dist/analytics/collector.js +146 -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 +53 -0
- package/dist/browser/index.js +330 -0
- package/dist/errors/capture.d.ts +52 -0
- package/dist/errors/capture.js +187 -0
- package/dist/errors/fingerprint.d.ts +36 -0
- package/dist/errors/fingerprint.js +211 -0
- package/dist/errors/reader.d.ts +19 -0
- package/dist/errors/reader.js +66 -0
- package/dist/errors/redact.d.ts +46 -0
- package/dist/errors/redact.js +93 -0
- package/dist/errors/tasks.d.ts +16 -0
- package/dist/errors/tasks.js +50 -0
- package/dist/fake/index.d.ts +5 -1
- package/dist/fake/index.js +28 -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 +61 -5
- package/dist/ingest-limit.d.ts +22 -0
- package/dist/ingest-limit.js +39 -0
- package/dist/migrations/0002_analytics.sql +350 -0
- package/dist/migrations/0003_errors.sql +118 -0
- package/dist/next/client-errors.d.ts +46 -0
- package/dist/next/client-errors.js +165 -0
- package/dist/next/index.d.ts +55 -0
- package/dist/next/index.js +72 -0
- package/dist/react/charts.d.ts +24 -0
- package/dist/react/charts.js +45 -0
- package/dist/react/errors.d.ts +12 -0
- package/dist/react/errors.js +17 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +52 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +8 -0
- package/dist/settings.d.ts +1 -0
- package/dist/settings.js +15 -8
- package/dist/tables.d.ts +2287 -253
- package/dist/tables.js +111 -1
- package/dist/types.d.ts +70 -2
- package/package.json +19 -1
package/dist/next/index.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/next/index.js
CHANGED
|
@@ -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');
|
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) => {
|