@wtfalch/reporting 0.3.1 → 0.4.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/dist/analytics/reader.d.ts +7 -0
- package/dist/analytics/reader.js +1 -1
- package/dist/errors/capture.d.ts +23 -1
- package/dist/errors/capture.js +90 -32
- package/dist/errors/reader.d.ts +7 -2
- package/dist/errors/reader.js +21 -3
- package/dist/errors/redact.d.ts +7 -1
- package/dist/errors/redact.js +12 -2
- package/dist/fake/index.d.ts +2 -0
- package/dist/fake/index.js +29 -0
- package/dist/index.js +28 -13
- package/dist/migrations/0004_environment.sql +36 -0
- package/dist/migrations/0005_analytics_session_index.sql +14 -0
- package/dist/migrations/0006_tenant_settings.sql +142 -0
- package/dist/next/edge-errors.d.ts +50 -0
- package/dist/next/edge-errors.js +131 -0
- package/dist/next/index.d.ts +10 -0
- package/dist/next/index.js +13 -0
- package/dist/reader.js +2 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +12 -0
- package/dist/settings.d.ts +21 -0
- package/dist/settings.js +53 -2
- package/dist/tables.d.ts +254 -0
- package/dist/tables.js +16 -0
- package/dist/types.d.ts +29 -0
- package/dist/writer.d.ts +1 -0
- package/dist/writer.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.4.0: per-tenant retention overrides. Mirrors src/tables.ts.
|
|
2
|
+
--
|
|
3
|
+
-- Retention is one setting per deployed app today (settings.ts:
|
|
4
|
+
-- events.retention_days, analytics.retention_days), all-or-nothing. A
|
|
5
|
+
-- multi-tenant company app the factory stamps may need a longer window for
|
|
6
|
+
-- one customer's contract (gap issue #11) without moving the default for
|
|
7
|
+
-- everyone else.
|
|
8
|
+
--
|
|
9
|
+
-- A separate table, not a row in reporting_settings: that table's key is
|
|
10
|
+
-- its whole primary key, with no tenant dimension, and giving it one would
|
|
11
|
+
-- change every existing key's shape. `reporting_tenant_settings` is the
|
|
12
|
+
-- same idea -- one JSONB value per key, an operator's edit, a plain read on
|
|
13
|
+
-- every housekeeping run -- keyed by (tenant_id, key) instead. Clearing an
|
|
14
|
+
-- override never deletes the row (same "nothing that deletes" convention
|
|
15
|
+
-- as reporting_settings): it sets the value to JSON null, and the prune
|
|
16
|
+
-- functions below treat that exactly like no row at all. Every statement is
|
|
17
|
+
-- idempotent.
|
|
18
|
+
|
|
19
|
+
create table if not exists reporting_tenant_settings (
|
|
20
|
+
tenant_id uuid not null,
|
|
21
|
+
key text not null,
|
|
22
|
+
value jsonb not null,
|
|
23
|
+
updated_at timestamptz not null default now(),
|
|
24
|
+
updated_by text,
|
|
25
|
+
constraint reporting_tenant_settings_pkey primary key (tenant_id, key),
|
|
26
|
+
-- Only the two retention keys this release supports; unlike
|
|
27
|
+
-- reporting_settings_key_check this is not a namespace pattern; there is
|
|
28
|
+
-- exactly one thing this table does.
|
|
29
|
+
constraint reporting_tenant_settings_key_check
|
|
30
|
+
check (key in ('events.retention_days', 'analytics.retention_days')),
|
|
31
|
+
-- JSON null (an explicit "no override") or a number in the same bounds
|
|
32
|
+
-- settings.ts's RETENTION_BOUNDS enforces in TypeScript.
|
|
33
|
+
constraint reporting_tenant_settings_value_check check (
|
|
34
|
+
jsonb_typeof(value) = 'null'
|
|
35
|
+
or (jsonb_typeof(value) = 'number' and (value #>> '{}')::numeric between 7 and 400)
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- ---------------------------------------------------- reporting_prune_events
|
|
40
|
+
-- Same clamping and batching as before; the per-row window now checks this
|
|
41
|
+
-- tenant's override first, falling back to the site-wide one. A row with no
|
|
42
|
+
-- tenant_id (most of them) never joins, so it always uses the default --
|
|
43
|
+
-- unaffected by this migration until an operator sets an override.
|
|
44
|
+
create or replace function reporting_prune_events(retention interval, batch integer)
|
|
45
|
+
returns integer
|
|
46
|
+
language plpgsql
|
|
47
|
+
security definer
|
|
48
|
+
set search_path = pg_catalog, public
|
|
49
|
+
as $$
|
|
50
|
+
declare
|
|
51
|
+
w interval := greatest(interval '7 days', least(coalesce(retention, interval '30 days'), interval '400 days'));
|
|
52
|
+
b integer := greatest(1, least(coalesce(batch, 5000), 5000));
|
|
53
|
+
n integer;
|
|
54
|
+
begin
|
|
55
|
+
with victims as (
|
|
56
|
+
select e.id
|
|
57
|
+
from reporting_events e
|
|
58
|
+
left join reporting_tenant_settings ts
|
|
59
|
+
on ts.tenant_id = e.tenant_id and ts.key = 'events.retention_days'
|
|
60
|
+
where e.occurred_at < now() - (
|
|
61
|
+
case when jsonb_typeof(ts.value) = 'number'
|
|
62
|
+
then greatest(interval '7 days', least((ts.value #>> '{}')::numeric * interval '1 day', interval '400 days'))
|
|
63
|
+
else w
|
|
64
|
+
end
|
|
65
|
+
)
|
|
66
|
+
order by e.occurred_at, e.id
|
|
67
|
+
limit b
|
|
68
|
+
)
|
|
69
|
+
delete from reporting_events ev using victims v where ev.id = v.id;
|
|
70
|
+
get diagnostics n = row_count;
|
|
71
|
+
return n;
|
|
72
|
+
end
|
|
73
|
+
$$;
|
|
74
|
+
revoke all on function reporting_prune_events(interval, integer) from public;
|
|
75
|
+
|
|
76
|
+
-- -------------------------------------------------- reporting_prune_analytics
|
|
77
|
+
-- Same shape: a tenant's own retention_days overrides the site-wide window,
|
|
78
|
+
-- still bounded by safe_before so pruning never outruns the rollups
|
|
79
|
+
-- (addendum A3) -- the per-tenant window is clamped the same 7-400 days
|
|
80
|
+
-- before comparison, so an override cannot defeat that guarantee either.
|
|
81
|
+
create or replace function reporting_prune_analytics(retention interval, batch integer, safe_before date)
|
|
82
|
+
returns integer
|
|
83
|
+
language plpgsql
|
|
84
|
+
security definer
|
|
85
|
+
set search_path = pg_catalog, public
|
|
86
|
+
as $$
|
|
87
|
+
declare
|
|
88
|
+
w interval := greatest(interval '7 days', least(coalesce(retention, interval '90 days'), interval '400 days'));
|
|
89
|
+
b integer := greatest(1, least(coalesce(batch, 5000), 5000));
|
|
90
|
+
cutoff timestamptz := now() - w;
|
|
91
|
+
n integer;
|
|
92
|
+
begin
|
|
93
|
+
if safe_before is null then
|
|
94
|
+
raise exception 'reporting_prune_analytics: the rollups have not completed a day yet; nothing is safe to prune';
|
|
95
|
+
end if;
|
|
96
|
+
if cutoff >= (safe_before::timestamp at time zone 'UTC') then
|
|
97
|
+
raise exception 'reporting_prune_analytics: cutoff % is not before the rollups'' watermark %', cutoff, safe_before;
|
|
98
|
+
end if;
|
|
99
|
+
with victims as (
|
|
100
|
+
select a.id
|
|
101
|
+
from reporting_analytics a
|
|
102
|
+
left join reporting_tenant_settings ts
|
|
103
|
+
on ts.tenant_id = a.tenant_id and ts.key = 'analytics.retention_days'
|
|
104
|
+
where a.occurred_at < least(
|
|
105
|
+
case when jsonb_typeof(ts.value) = 'number'
|
|
106
|
+
then now() - greatest(interval '7 days', least((ts.value #>> '{}')::numeric * interval '1 day', interval '400 days'))
|
|
107
|
+
else cutoff
|
|
108
|
+
end,
|
|
109
|
+
(safe_before::timestamp at time zone 'UTC')
|
|
110
|
+
)
|
|
111
|
+
order by a.occurred_at, a.id
|
|
112
|
+
limit b
|
|
113
|
+
)
|
|
114
|
+
delete from reporting_analytics an using victims v where an.id = v.id;
|
|
115
|
+
get diagnostics n = row_count;
|
|
116
|
+
return n;
|
|
117
|
+
end
|
|
118
|
+
$$;
|
|
119
|
+
revoke all on function reporting_prune_analytics(interval, integer, date) from public;
|
|
120
|
+
|
|
121
|
+
-- The runtime role reads and edits overrides (an operator's settings page),
|
|
122
|
+
-- same shape as reporting_settings: select, insert, update, nothing that
|
|
123
|
+
-- deletes -- clearing an override is a value update, not a row removal.
|
|
124
|
+
--
|
|
125
|
+
-- CREATE OR REPLACE FUNCTION keeps the two prune functions' existing grants
|
|
126
|
+
-- (their signatures are unchanged from 0001/0002); the two re-grants below
|
|
127
|
+
-- are a no-op on a host that already has them and a safety net on one
|
|
128
|
+
-- whose grants somehow drifted, cheap either way.
|
|
129
|
+
do $$
|
|
130
|
+
declare
|
|
131
|
+
rt text := current_database() || '_rt';
|
|
132
|
+
begin
|
|
133
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
134
|
+
execute format('revoke delete, truncate on reporting_tenant_settings from %I', rt);
|
|
135
|
+
execute format('grant execute on function reporting_prune_events(interval, integer) to %I', rt);
|
|
136
|
+
execute format(
|
|
137
|
+
'grant execute on function reporting_prune_analytics(interval, integer, date) to %I',
|
|
138
|
+
rt
|
|
139
|
+
);
|
|
140
|
+
end if;
|
|
141
|
+
end
|
|
142
|
+
$$;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Reporting } from '../types.js';
|
|
2
|
+
import type { NextOnRequestError } from './index.js';
|
|
3
|
+
export interface EdgeErrorReport {
|
|
4
|
+
readonly kind: string;
|
|
5
|
+
readonly message: string;
|
|
6
|
+
readonly stack: string | null;
|
|
7
|
+
readonly release: string | null;
|
|
8
|
+
readonly requestId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface EdgeCaptureOptions {
|
|
11
|
+
/** Where `edgeErrorIngestHandler` (./index.js) is mounted; same-origin path or an absolute URL. */
|
|
12
|
+
readonly endpoint: string;
|
|
13
|
+
/** Must match `EdgeErrorIngestOptions.secret` on the receiving end. */
|
|
14
|
+
readonly secret: string;
|
|
15
|
+
readonly release?: string | null;
|
|
16
|
+
/** Bounds how long a slow or hung node route can hold this up. Default 2000ms. */
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
/** For tests: replaces the global `fetch`. */
|
|
19
|
+
readonly fetch?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Binds to Next's `onRequestError` for the edge runtime. Builds the report
|
|
23
|
+
* from the error alone -- no `db`, no `Reporting` instance needed -- and
|
|
24
|
+
* POSTs it to the node route.
|
|
25
|
+
*
|
|
26
|
+
* Awaited, not fire-and-forget: `NextOnRequestError` may return a promise
|
|
27
|
+
* exactly so a hook like this can be waited on, and an edge isolate is free
|
|
28
|
+
* to be torn down the instant this function returns -- a POST left running
|
|
29
|
+
* behind an unawaited call is a coin flip on whether it ever leaves the
|
|
30
|
+
* process. `timeoutMs` bounds the wait so a hung node route cannot hold
|
|
31
|
+
* Next's own error handling open indefinitely. Never throws back into Next,
|
|
32
|
+
* the same rule `errorHandler` (./index.js) already keeps.
|
|
33
|
+
*/
|
|
34
|
+
export declare function edgeErrorHandler(options: EdgeCaptureOptions): NextOnRequestError;
|
|
35
|
+
export interface EdgeErrorIngestOptions {
|
|
36
|
+
readonly reporting: Reporting;
|
|
37
|
+
/** Must match `EdgeCaptureOptions.secret` on the sending end. */
|
|
38
|
+
readonly secret: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The node half of the pair: not a public ingest like `clientErrorHandler`'s
|
|
42
|
+
* (no origin allowance, no rate limiter, no batching) because it is not
|
|
43
|
+
* meant to be reachable except by this app's own edge runtime holding the
|
|
44
|
+
* shared secret. A request missing or failing that check is dropped the
|
|
45
|
+
* same silent way every ingest route in this package answers a bad one --
|
|
46
|
+
* always 204, so a prober learns nothing.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createEdgeErrorIngestCollector(options: EdgeErrorIngestOptions): {
|
|
49
|
+
handle(request: Request): Promise<Response>;
|
|
50
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* The edge runtime has no database connection (docs/plans/errors.md never
|
|
5
|
+
* covered it; `errorHandler`/`captureError` assume one). `errorHandler`
|
|
6
|
+
* (./index.js) still gets called there -- `NEXT_RUNTIME` says `edge` -- but
|
|
7
|
+
* a `Reporting` built over a driver that needs a TCP socket cannot even be
|
|
8
|
+
* constructed in that runtime, so the capture it tries to defer never runs.
|
|
9
|
+
*
|
|
10
|
+
* The fix is not to teach the edge runtime to write Postgres. It is to keep
|
|
11
|
+
* capture edge-safe: serialise the error and hand it to a node route
|
|
12
|
+
* handler over `fetch`, the one thing both runtimes share, and let that
|
|
13
|
+
* route -- which does have a `db` -- call `captureError` for real.
|
|
14
|
+
* `edgeErrorHandler` is the edge half; `createEdgeErrorIngestCollector`
|
|
15
|
+
* (wrapped as `edgeErrorIngestHandler` in ./index.js) is the node half.
|
|
16
|
+
*
|
|
17
|
+
* The shared `secret` is what stops a stranger from writing rows into
|
|
18
|
+
* `reporting_errors` through this route: unlike `clientErrorHandler`, whose
|
|
19
|
+
* public ingest is meant for any browser to call, this one exists only for
|
|
20
|
+
* this app's own edge runtime to call, and has no session or origin to
|
|
21
|
+
* check instead.
|
|
22
|
+
*/
|
|
23
|
+
const RAY_PATTERN = /^[0-9a-f]{16}-[A-Z0-9]{3,6}$/;
|
|
24
|
+
function headerValue(dict, name) {
|
|
25
|
+
const lower = name.toLowerCase();
|
|
26
|
+
for (const key of Object.keys(dict)) {
|
|
27
|
+
if (key.toLowerCase() !== lower)
|
|
28
|
+
continue;
|
|
29
|
+
const value = dict[key];
|
|
30
|
+
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Binds to Next's `onRequestError` for the edge runtime. Builds the report
|
|
36
|
+
* from the error alone -- no `db`, no `Reporting` instance needed -- and
|
|
37
|
+
* POSTs it to the node route.
|
|
38
|
+
*
|
|
39
|
+
* Awaited, not fire-and-forget: `NextOnRequestError` may return a promise
|
|
40
|
+
* exactly so a hook like this can be waited on, and an edge isolate is free
|
|
41
|
+
* to be torn down the instant this function returns -- a POST left running
|
|
42
|
+
* behind an unawaited call is a coin flip on whether it ever leaves the
|
|
43
|
+
* process. `timeoutMs` bounds the wait so a hung node route cannot hold
|
|
44
|
+
* Next's own error handling open indefinitely. Never throws back into Next,
|
|
45
|
+
* the same rule `errorHandler` (./index.js) already keeps.
|
|
46
|
+
*/
|
|
47
|
+
export function edgeErrorHandler(options) {
|
|
48
|
+
const doFetch = options.fetch ?? fetch;
|
|
49
|
+
return async (error, request) => {
|
|
50
|
+
try {
|
|
51
|
+
const ray = headerValue(request.headers, 'cf-ray');
|
|
52
|
+
const requestId = ray && RAY_PATTERN.test(ray) ? ray : randomUUID();
|
|
53
|
+
const body = {
|
|
54
|
+
kind: error instanceof Error ? error.name || 'Error' : 'Error',
|
|
55
|
+
message: error instanceof Error
|
|
56
|
+
? error.message || error.name || 'Error'
|
|
57
|
+
: typeof error === 'string'
|
|
58
|
+
? error
|
|
59
|
+
: 'unknown edge error',
|
|
60
|
+
stack: error instanceof Error && typeof error.stack === 'string' ? error.stack : null,
|
|
61
|
+
release: options.release ?? null,
|
|
62
|
+
requestId,
|
|
63
|
+
};
|
|
64
|
+
await doFetch(options.endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'content-type': 'application/json',
|
|
68
|
+
'x-reporting-edge-secret': options.secret,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 2000),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Never throw back into Next: see errorHandler's own doc comment. A
|
|
76
|
+
// failed or timed-out forward just means this occurrence is lost.
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const reportSchema = z.object({
|
|
81
|
+
kind: z.string().min(1),
|
|
82
|
+
message: z.string().min(1),
|
|
83
|
+
stack: z.string().nullish(),
|
|
84
|
+
release: z.string().nullish(),
|
|
85
|
+
requestId: z.string().min(1),
|
|
86
|
+
});
|
|
87
|
+
const NO_CONTENT = () => new Response(null, { status: 204 });
|
|
88
|
+
/** Constant-time so a network trace of response latency cannot narrow the secret. */
|
|
89
|
+
function secretMatches(provided, expected) {
|
|
90
|
+
const a = Buffer.from(provided);
|
|
91
|
+
const b = Buffer.from(expected);
|
|
92
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The node half of the pair: not a public ingest like `clientErrorHandler`'s
|
|
96
|
+
* (no origin allowance, no rate limiter, no batching) because it is not
|
|
97
|
+
* meant to be reachable except by this app's own edge runtime holding the
|
|
98
|
+
* shared secret. A request missing or failing that check is dropped the
|
|
99
|
+
* same silent way every ingest route in this package answers a bad one --
|
|
100
|
+
* always 204, so a prober learns nothing.
|
|
101
|
+
*/
|
|
102
|
+
export function createEdgeErrorIngestCollector(options) {
|
|
103
|
+
async function handle(request) {
|
|
104
|
+
if (request.method !== 'POST')
|
|
105
|
+
return NO_CONTENT();
|
|
106
|
+
const provided = request.headers.get('x-reporting-edge-secret');
|
|
107
|
+
if (!provided || !secretMatches(provided, options.secret))
|
|
108
|
+
return NO_CONTENT();
|
|
109
|
+
let json;
|
|
110
|
+
try {
|
|
111
|
+
json = await request.json();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return NO_CONTENT();
|
|
115
|
+
}
|
|
116
|
+
const parsed = reportSchema.safeParse(json);
|
|
117
|
+
if (!parsed.success)
|
|
118
|
+
return NO_CONTENT();
|
|
119
|
+
const { kind, message, stack, release, requestId } = parsed.data;
|
|
120
|
+
const err = new Error(message);
|
|
121
|
+
err.stack = stack ?? undefined;
|
|
122
|
+
options.reporting.captureError(err, {
|
|
123
|
+
kind,
|
|
124
|
+
runtime: 'edge',
|
|
125
|
+
release: release ?? null,
|
|
126
|
+
requestId,
|
|
127
|
+
});
|
|
128
|
+
return NO_CONTENT();
|
|
129
|
+
}
|
|
130
|
+
return { handle };
|
|
131
|
+
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { type CollectorOptions } from '../analytics/collector.js';
|
|
|
2
2
|
import { deviceOf } from '../analytics/schema.js';
|
|
3
3
|
import type { Reporting } from '../types.js';
|
|
4
4
|
import { type ClientErrorCollectorOptions } from './client-errors.js';
|
|
5
|
+
import { type EdgeErrorIngestOptions } from './edge-errors.js';
|
|
6
|
+
export { edgeErrorHandler, type EdgeCaptureOptions, type EdgeErrorReport } from './edge-errors.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.
|
|
@@ -88,6 +90,14 @@ export declare function errorHandler(reporting: Reporting): NextOnRequestError;
|
|
|
88
90
|
* origin allowance and why identity is never the body's.
|
|
89
91
|
*/
|
|
90
92
|
export declare function clientErrorHandler(options: ClientErrorCollectorOptions): (request: Request) => Promise<Response>;
|
|
93
|
+
/**
|
|
94
|
+
* The node half of `edgeErrorHandler` (./edge-errors.js): mount as `POST` of
|
|
95
|
+
* an internal route (docs/plans/errors.md has no "Surface" entry for this,
|
|
96
|
+
* because it is not a public one -- `// authz: public` would be wrong here;
|
|
97
|
+
* the shared `secret` is the gate). This is where `reporting`'s real `db`
|
|
98
|
+
* lives, so this is where the edge-forwarded report finally becomes a row.
|
|
99
|
+
*/
|
|
100
|
+
export declare function edgeErrorIngestHandler(options: EdgeErrorIngestOptions): (request: Request) => Promise<Response>;
|
|
91
101
|
/** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
|
|
92
102
|
export declare function trackContext(): Promise<{
|
|
93
103
|
device: ReturnType<typeof deviceOf>;
|
package/dist/next/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { after, connection } from 'next/server';
|
|
|
5
5
|
import { createCollector } from '../analytics/collector.js';
|
|
6
6
|
import { countryOf, deviceOf } from '../analytics/schema.js';
|
|
7
7
|
import { createClientErrorCollector } from './client-errors.js';
|
|
8
|
+
import { createEdgeErrorIngestCollector } from './edge-errors.js';
|
|
9
|
+
export { edgeErrorHandler } from './edge-errors.js';
|
|
8
10
|
/**
|
|
9
11
|
* The Next bindings: everything in the package that knows what a request is.
|
|
10
12
|
* Peer on `next`; a host without Next never imports this entry.
|
|
@@ -132,6 +134,17 @@ export function clientErrorHandler(options) {
|
|
|
132
134
|
const collector = createClientErrorCollector(options);
|
|
133
135
|
return (request) => collector.handle(request);
|
|
134
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The node half of `edgeErrorHandler` (./edge-errors.js): mount as `POST` of
|
|
139
|
+
* an internal route (docs/plans/errors.md has no "Surface" entry for this,
|
|
140
|
+
* because it is not a public one -- `// authz: public` would be wrong here;
|
|
141
|
+
* the shared `secret` is the gate). This is where `reporting`'s real `db`
|
|
142
|
+
* lives, so this is where the edge-forwarded report finally becomes a row.
|
|
143
|
+
*/
|
|
144
|
+
export function edgeErrorIngestHandler(options) {
|
|
145
|
+
const collector = createEdgeErrorIngestCollector(options);
|
|
146
|
+
return (request) => collector.handle(request);
|
|
147
|
+
}
|
|
135
148
|
/** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
|
|
136
149
|
export async function trackContext() {
|
|
137
150
|
try {
|
package/dist/reader.js
CHANGED
|
@@ -19,6 +19,8 @@ export async function eventsPage(db, opts = {}) {
|
|
|
19
19
|
conditions.push(eq(reportingEvents.site, opts.site));
|
|
20
20
|
if (opts.requestId)
|
|
21
21
|
conditions.push(eq(reportingEvents.requestId, opts.requestId));
|
|
22
|
+
if (opts.environment)
|
|
23
|
+
conditions.push(eq(reportingEvents.environment, opts.environment));
|
|
22
24
|
if (opts.after) {
|
|
23
25
|
const { occurredAt, id } = opts.after;
|
|
24
26
|
const keyset = or(lt(reportingEvents.occurredAt, occurredAt), and(eq(reportingEvents.occurredAt, occurredAt), lt(reportingEvents.id, id)));
|
package/dist/schema.d.ts
CHANGED
|
@@ -90,4 +90,14 @@ export declare const eventInputSchema: z.ZodObject<{
|
|
|
90
90
|
export type EventInput = z.input<typeof eventInputSchema>;
|
|
91
91
|
export type ValidEvent = z.output<typeof eventInputSchema>;
|
|
92
92
|
export declare const siteSchema: z.ZodString;
|
|
93
|
+
/**
|
|
94
|
+
* Deploy target, not per-event: stamped once on `ReportingOptions.environment`,
|
|
95
|
+
* the same as `site`, because a preview or stage deploy is a whole process
|
|
96
|
+
* running with that value, not a claim an individual event makes (gap issue
|
|
97
|
+
* #6). Unlike `runtime` or `level`, the set of names is not fixed -- a host
|
|
98
|
+
* may run `production`/`stage`/`preview`, or its own scheme -- so this only
|
|
99
|
+
* keeps it slug-shaped.
|
|
100
|
+
*/
|
|
101
|
+
export declare const ENVIRONMENT_PATTERN: RegExp;
|
|
102
|
+
export declare const environmentSchema: z.ZodString;
|
|
93
103
|
export declare const fingerprintSchema: z.ZodString;
|
package/dist/schema.js
CHANGED
|
@@ -90,6 +90,18 @@ export const eventInputSchema = z.strictObject({
|
|
|
90
90
|
export const siteSchema = z
|
|
91
91
|
.string()
|
|
92
92
|
.regex(SITE_PATTERN, 'a site id is lower case, digits and dashes');
|
|
93
|
+
/**
|
|
94
|
+
* Deploy target, not per-event: stamped once on `ReportingOptions.environment`,
|
|
95
|
+
* the same as `site`, because a preview or stage deploy is a whole process
|
|
96
|
+
* running with that value, not a claim an individual event makes (gap issue
|
|
97
|
+
* #6). Unlike `runtime` or `level`, the set of names is not fixed -- a host
|
|
98
|
+
* may run `production`/`stage`/`preview`, or its own scheme -- so this only
|
|
99
|
+
* keeps it slug-shaped.
|
|
100
|
+
*/
|
|
101
|
+
export const ENVIRONMENT_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
102
|
+
export const environmentSchema = z
|
|
103
|
+
.string()
|
|
104
|
+
.regex(ENVIRONMENT_PATTERN, 'an environment is lower case, digits, dashes and underscores');
|
|
93
105
|
export const fingerprintSchema = z
|
|
94
106
|
.string()
|
|
95
107
|
.regex(FINGERPRINT_PATTERN, 'a fingerprint is 32 lower-case hex characters');
|
package/dist/settings.d.ts
CHANGED
|
@@ -14,3 +14,24 @@ export declare const DEFAULT_SETTINGS: Settings;
|
|
|
14
14
|
export declare function getSettings(db: Db): Promise<Settings>;
|
|
15
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. */
|
|
16
16
|
export declare function setSettings(db: Db, patch: Partial<Settings>, by: Actor): Promise<SettingsChange>;
|
|
17
|
+
/**
|
|
18
|
+
* A multi-tenant company app may need a longer or shorter retention window
|
|
19
|
+
* for one customer's contract without moving the site-wide default (gap
|
|
20
|
+
* issue #11): `reporting_tenant_settings`, keyed by (tenant_id, key), one
|
|
21
|
+
* JSONB value the same shape `getSettings`/`setSettings` already parse.
|
|
22
|
+
* `reporting_prune_events`/`reporting_prune_analytics` (0006_tenant_settings.sql)
|
|
23
|
+
* check this table for the row they are about to prune before falling back
|
|
24
|
+
* to the site-wide setting.
|
|
25
|
+
*/
|
|
26
|
+
export declare const TENANT_RETENTION_KEYS: readonly ["events.retention_days", "analytics.retention_days"];
|
|
27
|
+
export type TenantRetentionKey = (typeof TENANT_RETENTION_KEYS)[number];
|
|
28
|
+
/** Only the keys this tenant has overridden; a key absent here means "use the site-wide default". */
|
|
29
|
+
export declare function getTenantRetention(db: Db, tenantId: string): Promise<Partial<Record<TenantRetentionKey, number>>>;
|
|
30
|
+
/**
|
|
31
|
+
* Sets a tenant's override, or clears it back to the site-wide default when
|
|
32
|
+
* `days` is `null` -- clearing never deletes the row, same "nothing that
|
|
33
|
+
* deletes" convention as `reportingSettings`: it stores the JSON `null`
|
|
34
|
+
* `reporting_prune_events`/`reporting_prune_analytics` already treat as "no
|
|
35
|
+
* override". Throws on an out-of-bounds value: the caller is a form.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setTenantRetention(db: Db, tenantId: string, key: TenantRetentionKey, days: number | null, by: Actor): Promise<void>;
|
package/dist/settings.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { sql } from 'drizzle-orm';
|
|
1
|
+
import { eq, sql } from 'drizzle-orm';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { reportingSettings } from './tables.js';
|
|
3
|
+
import { reportingSettings, reportingTenantSettings } from './tables.js';
|
|
4
4
|
/**
|
|
5
5
|
* Operator-edited settings, one row per key, read by housekeeping on every
|
|
6
6
|
* run. Bounds are enforced here and again inside `reporting_prune_events`,
|
|
@@ -75,3 +75,54 @@ export async function setSettings(db, patch, by) {
|
|
|
75
75
|
return { before, after, by, changed };
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* A multi-tenant company app may need a longer or shorter retention window
|
|
80
|
+
* for one customer's contract without moving the site-wide default (gap
|
|
81
|
+
* issue #11): `reporting_tenant_settings`, keyed by (tenant_id, key), one
|
|
82
|
+
* JSONB value the same shape `getSettings`/`setSettings` already parse.
|
|
83
|
+
* `reporting_prune_events`/`reporting_prune_analytics` (0006_tenant_settings.sql)
|
|
84
|
+
* check this table for the row they are about to prune before falling back
|
|
85
|
+
* to the site-wide setting.
|
|
86
|
+
*/
|
|
87
|
+
export const TENANT_RETENTION_KEYS = ['events.retention_days', 'analytics.retention_days'];
|
|
88
|
+
/** Only the keys this tenant has overridden; a key absent here means "use the site-wide default". */
|
|
89
|
+
export async function getTenantRetention(db, tenantId) {
|
|
90
|
+
const rows = await db
|
|
91
|
+
.select({ key: reportingTenantSettings.key, value: reportingTenantSettings.value })
|
|
92
|
+
.from(reportingTenantSettings)
|
|
93
|
+
.where(eq(reportingTenantSettings.tenantId, tenantId));
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
if (typeof row.value === 'number' && Number.isInteger(row.value)) {
|
|
97
|
+
out[row.key] = row.value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Sets a tenant's override, or clears it back to the site-wide default when
|
|
104
|
+
* `days` is `null` -- clearing never deletes the row, same "nothing that
|
|
105
|
+
* deletes" convention as `reportingSettings`: it stores the JSON `null`
|
|
106
|
+
* `reporting_prune_events`/`reporting_prune_analytics` already treat as "no
|
|
107
|
+
* override". Throws on an out-of-bounds value: the caller is a form.
|
|
108
|
+
*/
|
|
109
|
+
export async function setTenantRetention(db, tenantId, key, days, by) {
|
|
110
|
+
if (days !== null) {
|
|
111
|
+
const parsed = retention.safeParse(days);
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
throw new Error(`reporting.tenantSettings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// The column is `jsonb not null`, and a JS `null` value maps to a SQL
|
|
117
|
+
// NULL parameter, not the JSON literal `null` -- an explicit cast is the
|
|
118
|
+
// only way to store "no override" as the value the CHECK and the prune
|
|
119
|
+
// functions both expect, rather than violate the NOT NULL constraint.
|
|
120
|
+
const value = days === null ? sql `'null'::jsonb` : sql `to_jsonb(${days}::int)`;
|
|
121
|
+
await db
|
|
122
|
+
.insert(reportingTenantSettings)
|
|
123
|
+
.values({ tenantId, key, value, updatedBy: `${by.class}:${by.id}` })
|
|
124
|
+
.onConflictDoUpdate({
|
|
125
|
+
target: [reportingTenantSettings.tenantId, reportingTenantSettings.key],
|
|
126
|
+
set: { value, updatedAt: sql `now()`, updatedBy: `${by.class}:${by.id}` },
|
|
127
|
+
});
|
|
128
|
+
}
|