@wtfalch/reporting 0.5.1 → 0.8.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 +115 -15
- package/dist/analytics/write.js +3 -0
- package/dist/deploy.d.ts +11 -0
- package/dist/deploy.js +13 -0
- package/dist/errors/capture.js +46 -7
- package/dist/errors/reader.d.ts +22 -3
- package/dist/errors/reader.js +84 -21
- package/dist/errors/redact.d.ts +1 -0
- package/dist/errors/redact.js +63 -0
- package/dist/errors/tasks.d.ts +15 -0
- package/dist/errors/tasks.js +47 -0
- package/dist/housekeeping/index.d.ts +2 -0
- package/dist/housekeeping/index.js +2 -0
- package/dist/index.js +13 -1
- package/dist/migrations/0001_reporting.sql +6 -20
- package/dist/migrations/0002_analytics.sql +6 -20
- package/dist/migrations/0003_errors.sql +4 -16
- package/dist/migrations/0006_tenant_settings.sql +3 -21
- package/dist/migrations/0007_errors_open_cap.sql +42 -0
- package/dist/migrations/0008_error_tenants.sql +35 -0
- package/dist/migrations/0009_release_color.sql +33 -0
- package/dist/migrations/0010_requests.sql +27 -0
- package/dist/migrations-dir.d.ts +11 -0
- package/dist/migrations-dir.js +12 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/release-health.d.ts +22 -0
- package/dist/next/release-health.js +122 -0
- package/dist/react/errors.d.ts +2 -2
- package/dist/react/errors.js +1 -1
- package/dist/requests.d.ts +22 -0
- package/dist/requests.js +72 -0
- package/dist/tables.d.ts +609 -16
- package/dist/tables.js +32 -0
- package/dist/types.d.ts +12 -4
- package/dist/writer.js +2 -0
- package/package.json +8 -8
- package/dist/fake/index.d.ts +0 -22
- package/dist/fake/index.js +0 -157
|
@@ -64,3 +64,5 @@ export interface Housekeeping {
|
|
|
64
64
|
export declare function createHousekeeping(options: HousekeepingOptions): Housekeeping;
|
|
65
65
|
export { pruneEvents, retentionLag } from './tasks.js';
|
|
66
66
|
export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
|
|
67
|
+
export { pruneOpenErrors } from '../errors/tasks.js';
|
|
68
|
+
export { pruneErrors } from '../errors/tasks.js';
|
|
@@ -209,3 +209,5 @@ export function createHousekeeping(options) {
|
|
|
209
209
|
}
|
|
210
210
|
export { pruneEvents, retentionLag } from './tasks.js';
|
|
211
211
|
export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
|
|
212
|
+
export { pruneOpenErrors } from '../errors/tasks.js';
|
|
213
|
+
export { pruneErrors } from '../errors/tasks.js';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ANALYTICS_NAME_PATTERN, normalisePath, propsSchema } from './analytics/
|
|
|
4
4
|
import { insertAnalytics } from './analytics/write.js';
|
|
5
5
|
import { createCapture } from './errors/capture.js';
|
|
6
6
|
import { eventsPage } from './reader.js';
|
|
7
|
+
import { RequestCounter } from './requests.js';
|
|
7
8
|
import { environmentSchema, siteSchema } from './schema.js';
|
|
8
9
|
import { getSettings, getTenantRetention, setSettings, setTenantRetention } from './settings.js';
|
|
9
10
|
import { tables } from './tables.js';
|
|
@@ -54,6 +55,13 @@ export function createReporting(options) {
|
|
|
54
55
|
batchSize: options.batchSize ?? 500,
|
|
55
56
|
flushEveryMs: options.flushEveryMs ?? 1000,
|
|
56
57
|
});
|
|
58
|
+
const requests = new RequestCounter({
|
|
59
|
+
db: options.db,
|
|
60
|
+
log: options.log,
|
|
61
|
+
site,
|
|
62
|
+
now,
|
|
63
|
+
flushEveryMs: options.requestFlushEveryMs ?? 10_000,
|
|
64
|
+
});
|
|
57
65
|
// One alerting path, shared by `reporting.alert()` and a new-or-reopened
|
|
58
66
|
// capture (errors/capture.ts's `alert` option): an `alert.<check>` row,
|
|
59
67
|
// already indexed and pruned the same as everything else on the
|
|
@@ -91,7 +99,11 @@ export function createReporting(options) {
|
|
|
91
99
|
event: (input) => writer.event(input),
|
|
92
100
|
captureError: (error, context) => capture(error, context),
|
|
93
101
|
alert: fireAlert,
|
|
94
|
-
flush: (opts) =>
|
|
102
|
+
flush: async (opts) => {
|
|
103
|
+
await writer.flush(opts);
|
|
104
|
+
await requests.flush();
|
|
105
|
+
},
|
|
106
|
+
requests: { record: (status) => requests.record(status) },
|
|
95
107
|
events: { page: (opts) => eventsPage(options.db, opts) },
|
|
96
108
|
settings: {
|
|
97
109
|
get: () => getSettings(options.db),
|
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
-- after the host copies this file into its drizzle/ directory as the next
|
|
6
6
|
-- number (reporting-migrations); never edited there.
|
|
7
7
|
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
-- effect, which the package README says plainly.
|
|
8
|
+
-- The SQL names no role and no schema. The host applies it into its own
|
|
9
|
+
-- schema and makes the runtime role itself; the package README lists the
|
|
10
|
+
-- privileges to pass to ensureRuntimeRole.
|
|
12
11
|
|
|
13
12
|
create table if not exists reporting_events (
|
|
14
13
|
id bigint generated always as identity primary key,
|
|
@@ -114,7 +113,7 @@ create or replace function reporting_prune_events(retention interval, batch inte
|
|
|
114
113
|
returns integer
|
|
115
114
|
language plpgsql
|
|
116
115
|
security definer
|
|
117
|
-
set search_path
|
|
116
|
+
set search_path from current
|
|
118
117
|
as $$
|
|
119
118
|
declare
|
|
120
119
|
w interval := greatest(interval '7 days', least(coalesce(retention, interval '30 days'), interval '400 days'));
|
|
@@ -136,18 +135,5 @@ revoke all on function reporting_prune_events(interval, integer) from public;
|
|
|
136
135
|
|
|
137
136
|
-- The runtime role keeps SELECT and INSERT on all three tables, UPDATE on
|
|
138
137
|
-- tasks and settings because the claim protocol and the settings page write
|
|
139
|
-
-- them, and nothing that deletes. The host's
|
|
140
|
-
--
|
|
141
|
-
-- same file.
|
|
142
|
-
do $$
|
|
143
|
-
declare
|
|
144
|
-
rt text := current_database() || '_rt';
|
|
145
|
-
begin
|
|
146
|
-
if exists (select 1 from pg_roles where rolname = rt) then
|
|
147
|
-
execute format('revoke update, delete, truncate on reporting_events from %I', rt);
|
|
148
|
-
execute format('revoke delete, truncate on reporting_tasks from %I', rt);
|
|
149
|
-
execute format('revoke delete, truncate on reporting_settings from %I', rt);
|
|
150
|
-
execute format('grant execute on function reporting_prune_events(interval, integer) to %I', rt);
|
|
151
|
-
end if;
|
|
152
|
-
end
|
|
153
|
-
$$;
|
|
138
|
+
-- them, and nothing that deletes. The host's ensureRuntimeRole call sets
|
|
139
|
+
-- that; see the README.
|
|
@@ -135,7 +135,7 @@ create or replace function reporting_rollup_day(d date)
|
|
|
135
135
|
returns integer
|
|
136
136
|
language plpgsql
|
|
137
137
|
security definer
|
|
138
|
-
set search_path
|
|
138
|
+
set search_path from current
|
|
139
139
|
as $$
|
|
140
140
|
declare
|
|
141
141
|
n integer;
|
|
@@ -231,7 +231,7 @@ create or replace function reporting_rollup_week(w date)
|
|
|
231
231
|
returns integer
|
|
232
232
|
language plpgsql
|
|
233
233
|
security definer
|
|
234
|
-
set search_path
|
|
234
|
+
set search_path from current
|
|
235
235
|
as $$
|
|
236
236
|
declare
|
|
237
237
|
n integer;
|
|
@@ -282,7 +282,7 @@ create or replace function reporting_prune_analytics(retention interval, batch i
|
|
|
282
282
|
returns integer
|
|
283
283
|
language plpgsql
|
|
284
284
|
security definer
|
|
285
|
-
set search_path
|
|
285
|
+
set search_path from current
|
|
286
286
|
as $$
|
|
287
287
|
declare
|
|
288
288
|
w interval := greatest(interval '7 days', least(coalesce(retention, interval '90 days'), interval '400 days'));
|
|
@@ -316,7 +316,7 @@ create or replace function reporting_erase_person(subject text)
|
|
|
316
316
|
returns integer
|
|
317
317
|
language plpgsql
|
|
318
318
|
security definer
|
|
319
|
-
set search_path
|
|
319
|
+
set search_path from current
|
|
320
320
|
as $$
|
|
321
321
|
declare
|
|
322
322
|
n integer;
|
|
@@ -332,19 +332,5 @@ $$;
|
|
|
332
332
|
revoke all on function reporting_erase_person(text) from public;
|
|
333
333
|
|
|
334
334
|
-- The runtime role inserts and reads raw rows, reads the rollups, and
|
|
335
|
-
-- changes either only through the functions above
|
|
336
|
-
|
|
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
|
-
$$;
|
|
335
|
+
-- changes either only through the functions above (the host's
|
|
336
|
+
-- ensureRuntimeRole call; see the README).
|
|
@@ -7,10 +7,8 @@
|
|
|
7
7
|
-- copies this file into its drizzle/ directory as the next number
|
|
8
8
|
-- (reporting-migrations); never edited there.
|
|
9
9
|
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
12
|
-
-- without that role gets the table and no revokes take effect, which the
|
|
13
|
-
-- package README says plainly.
|
|
10
|
+
-- The SQL names no role and no schema; the host's ensureRuntimeRole call
|
|
11
|
+
-- sets the runtime role's privileges (see the README).
|
|
14
12
|
|
|
15
13
|
create table if not exists reporting_errors (
|
|
16
14
|
fingerprint text primary key,
|
|
@@ -77,7 +75,7 @@ create or replace function reporting_prune_errors(retention interval, batch inte
|
|
|
77
75
|
returns integer
|
|
78
76
|
language plpgsql
|
|
79
77
|
security definer
|
|
80
|
-
set search_path
|
|
78
|
+
set search_path from current
|
|
81
79
|
as $$
|
|
82
80
|
declare
|
|
83
81
|
w interval := greatest(interval '7 days', least(coalesce(retention, interval '30 days'), interval '400 days'));
|
|
@@ -105,14 +103,4 @@ comment on function reporting_prune_errors(interval, integer) is
|
|
|
105
103
|
-- on repeat occurrences and an operator moves state through this table.
|
|
106
104
|
-- Nothing that deletes, the same as reporting_tasks and reporting_settings;
|
|
107
105
|
-- reporting_prune_errors runs as the function owner, not the runtime role,
|
|
108
|
-
-- so the
|
|
109
|
-
do $$
|
|
110
|
-
declare
|
|
111
|
-
rt text := current_database() || '_rt';
|
|
112
|
-
begin
|
|
113
|
-
if exists (select 1 from pg_roles where rolname = rt) then
|
|
114
|
-
execute format('revoke delete, truncate on reporting_errors from %I', rt);
|
|
115
|
-
execute format('grant execute on function reporting_prune_errors(interval, integer) to %I', rt);
|
|
116
|
-
end if;
|
|
117
|
-
end
|
|
118
|
-
$$;
|
|
106
|
+
-- so the runtime role needs execute only, same shape as reporting_prune_events.
|
|
@@ -45,7 +45,7 @@ create or replace function reporting_prune_events(retention interval, batch inte
|
|
|
45
45
|
returns integer
|
|
46
46
|
language plpgsql
|
|
47
47
|
security definer
|
|
48
|
-
set search_path
|
|
48
|
+
set search_path from current
|
|
49
49
|
as $$
|
|
50
50
|
declare
|
|
51
51
|
w interval := greatest(interval '7 days', least(coalesce(retention, interval '30 days'), interval '400 days'));
|
|
@@ -82,7 +82,7 @@ create or replace function reporting_prune_analytics(retention interval, batch i
|
|
|
82
82
|
returns integer
|
|
83
83
|
language plpgsql
|
|
84
84
|
security definer
|
|
85
|
-
set search_path
|
|
85
|
+
set search_path from current
|
|
86
86
|
as $$
|
|
87
87
|
declare
|
|
88
88
|
w interval := greatest(interval '7 days', least(coalesce(retention, interval '90 days'), interval '400 days'));
|
|
@@ -121,22 +121,4 @@ revoke all on function reporting_prune_analytics(interval, integer, date) from p
|
|
|
121
121
|
-- The runtime role reads and edits overrides (an operator's settings page),
|
|
122
122
|
-- same shape as reporting_settings: select, insert, update, nothing that
|
|
123
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
|
-
$$;
|
|
124
|
+
-- The host's ensureRuntimeRole call sets that; see the README.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.6.0: a ceiling on open error groups per site.
|
|
2
|
+
--
|
|
3
|
+
-- reporting_prune_errors (0003) never deletes an 'open' row, on purpose. But
|
|
4
|
+
-- the public ingest route lets an unauthenticated caller create a new group
|
|
5
|
+
-- per distinct message, so "never" also means "without bound". This function
|
|
6
|
+
-- is the bound: when a site holds more than `cap` open groups, the ones seen
|
|
7
|
+
-- least recently leave first. An incident that is still firing keeps its
|
|
8
|
+
-- group, because every occurrence moves last_seen_at forward; a flood of
|
|
9
|
+
-- one-off messages pushes out only other one-off messages. Idempotent.
|
|
10
|
+
--
|
|
11
|
+
-- `cap` is clamped to at least 100 so a bad argument cannot empty the table;
|
|
12
|
+
-- `batch` to between 1 and 5,000, same as reporting_prune_errors.
|
|
13
|
+
create or replace function reporting_prune_open_errors(cap integer, batch integer)
|
|
14
|
+
returns integer
|
|
15
|
+
language plpgsql
|
|
16
|
+
security definer
|
|
17
|
+
set search_path from current
|
|
18
|
+
as $$
|
|
19
|
+
declare
|
|
20
|
+
c integer := greatest(100, coalesce(cap, 5000));
|
|
21
|
+
b integer := greatest(1, least(coalesce(batch, 5000), 5000));
|
|
22
|
+
n integer;
|
|
23
|
+
begin
|
|
24
|
+
with ranked as (
|
|
25
|
+
select fingerprint,
|
|
26
|
+
row_number() over (partition by site order by last_seen_at desc, fingerprint) as rn
|
|
27
|
+
from reporting_errors
|
|
28
|
+
where state = 'open'
|
|
29
|
+
),
|
|
30
|
+
victims as (
|
|
31
|
+
select fingerprint from ranked where rn > c order by rn desc limit b
|
|
32
|
+
)
|
|
33
|
+
delete from reporting_errors e using victims v where e.fingerprint = v.fingerprint;
|
|
34
|
+
get diagnostics n = row_count;
|
|
35
|
+
return n;
|
|
36
|
+
end
|
|
37
|
+
$$;
|
|
38
|
+
revoke all on function reporting_prune_open_errors(integer, integer) from public;
|
|
39
|
+
|
|
40
|
+
comment on function reporting_prune_open_errors(integer, integer) is
|
|
41
|
+
'Deletes the least recently seen open groups beyond `cap` per site. The only way an open row leaves on a timer: a bound on unauthenticated ingest, not an age limit.';
|
|
42
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.6.0: per-tenant occurrence tracking for error groups.
|
|
2
|
+
-- Mirrors src/tables.ts.
|
|
3
|
+
--
|
|
4
|
+
-- reporting_errors keeps one row per fingerprint, so two tenants that hit the
|
|
5
|
+
-- same bug share a row whose tenant_id is whichever occurrence came last.
|
|
6
|
+
-- That silently moves the group between tenants and gives a tenant-scoped
|
|
7
|
+
-- reader nothing to filter on. This table records, per fingerprint and
|
|
8
|
+
-- tenant, that the tenant saw the error, how often and when. Readers filter
|
|
9
|
+
-- and count from here when given a tenantId; the shared row is unchanged.
|
|
10
|
+
--
|
|
11
|
+
-- The foreign key cascades, so a pruned group takes its tenant rows with it
|
|
12
|
+
-- (the prune functions run as the table owner; the runtime role never
|
|
13
|
+
-- deletes). Every statement is idempotent.
|
|
14
|
+
|
|
15
|
+
create table if not exists reporting_error_tenants (
|
|
16
|
+
fingerprint text not null references reporting_errors (fingerprint) on delete cascade,
|
|
17
|
+
tenant_id uuid not null,
|
|
18
|
+
occurrences bigint not null default 1,
|
|
19
|
+
first_seen_at timestamptz not null,
|
|
20
|
+
last_seen_at timestamptz not null,
|
|
21
|
+
constraint reporting_error_tenants_pkey primary key (fingerprint, tenant_id),
|
|
22
|
+
constraint reporting_error_tenants_fingerprint_check check (fingerprint ~ '^[0-9a-f]{32}$'),
|
|
23
|
+
constraint reporting_error_tenants_occurrences_check check (occurrences > 0),
|
|
24
|
+
constraint reporting_error_tenants_seen_check check (last_seen_at >= first_seen_at)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
create index if not exists reporting_error_tenants_tenant_idx
|
|
28
|
+
on reporting_error_tenants (tenant_id, last_seen_at desc);
|
|
29
|
+
|
|
30
|
+
-- No backfill. reporting_errors holds only the latest tenant per group, so
|
|
31
|
+
-- copying its occurrences to that tenant would credit it with every other
|
|
32
|
+
-- tenant's history. Counts per tenant start at the deploy of 0.6.0.
|
|
33
|
+
|
|
34
|
+
-- Select, insert and update for the runtime role; nothing that deletes (the
|
|
35
|
+
-- host's ensureRuntimeRole call sets that; see the README).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.8.0: the release and color a row was written under.
|
|
2
|
+
-- Mirrors src/tables.ts.
|
|
3
|
+
--
|
|
4
|
+
-- A blue/green deploy runs two copies of an app against one database. The
|
|
5
|
+
-- host gets REPORTING_RELEASE (an image digest) and REPORTING_COLOR (blue or
|
|
6
|
+
-- green) from its platform, and the package stamps both on every row it
|
|
7
|
+
-- writes. Both are nullable: rows written before this file, and rows from an
|
|
8
|
+
-- app with no color, carry null. reporting_errors already has `release`.
|
|
9
|
+
--
|
|
10
|
+
-- Same shape as 0004_environment.sql: `add column if not exists` takes only a
|
|
11
|
+
-- catalog lock, and the CHECK goes in `not valid` then `validate constraint`
|
|
12
|
+
-- so the scan of a large table does not hold ACCESS EXCLUSIVE. Every
|
|
13
|
+
-- statement is idempotent; no backfill.
|
|
14
|
+
|
|
15
|
+
alter table reporting_events add column if not exists release text;
|
|
16
|
+
alter table reporting_events add column if not exists color text;
|
|
17
|
+
alter table reporting_events drop constraint if exists reporting_events_color_check;
|
|
18
|
+
alter table reporting_events add constraint reporting_events_color_check
|
|
19
|
+
check (color is null or color in ('blue', 'green')) not valid;
|
|
20
|
+
alter table reporting_events validate constraint reporting_events_color_check;
|
|
21
|
+
|
|
22
|
+
alter table reporting_analytics add column if not exists release text;
|
|
23
|
+
alter table reporting_analytics add column if not exists color text;
|
|
24
|
+
alter table reporting_analytics drop constraint if exists reporting_analytics_color_check;
|
|
25
|
+
alter table reporting_analytics add constraint reporting_analytics_color_check
|
|
26
|
+
check (color is null or color in ('blue', 'green')) not valid;
|
|
27
|
+
alter table reporting_analytics validate constraint reporting_analytics_color_check;
|
|
28
|
+
|
|
29
|
+
alter table reporting_errors add column if not exists color text;
|
|
30
|
+
alter table reporting_errors drop constraint if exists reporting_errors_color_check;
|
|
31
|
+
alter table reporting_errors add constraint reporting_errors_color_check
|
|
32
|
+
check (color is null or color in ('blue', 'green')) not valid;
|
|
33
|
+
alter table reporting_errors validate constraint reporting_errors_color_check;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.8.0: a request counter, one row per minute.
|
|
2
|
+
-- Mirrors src/tables.ts.
|
|
3
|
+
--
|
|
4
|
+
-- Blue/green judges a new color by its share of failed responses, which needs
|
|
5
|
+
-- a count of all requests, not one event per request. The app counts in
|
|
6
|
+
-- memory and writes one upsert per minute bucket, color and release.
|
|
7
|
+
-- `errors` is responses with status >= 500. `color` and `release` are the
|
|
8
|
+
-- empty string, not null, when the app has none, so they can sit in the
|
|
9
|
+
-- primary key. Counts only; nothing here identifies a person or a request.
|
|
10
|
+
-- Every statement is idempotent.
|
|
11
|
+
--
|
|
12
|
+
-- The table is new, so there are no live rows to scan and the CHECKs go in
|
|
13
|
+
-- the plain way. Select, insert and update for the runtime role; nothing that
|
|
14
|
+
-- deletes (the host's ensureRuntimeRole call sets that; see the README).
|
|
15
|
+
|
|
16
|
+
create table if not exists reporting_requests (
|
|
17
|
+
bucket timestamptz not null,
|
|
18
|
+
site text not null,
|
|
19
|
+
color text not null default '',
|
|
20
|
+
release text not null default '',
|
|
21
|
+
requests bigint not null default 0,
|
|
22
|
+
errors bigint not null default 0,
|
|
23
|
+
constraint reporting_requests_pkey primary key (bucket, site, color, release),
|
|
24
|
+
constraint reporting_requests_site_check check (site ~ '^[a-z][a-z0-9-]{0,63}$'),
|
|
25
|
+
constraint reporting_requests_color_check check (color in ('', 'blue', 'green')),
|
|
26
|
+
constraint reporting_requests_counts_check check (requests >= 0 and errors between 0 and requests)
|
|
27
|
+
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute path of the directory holding this package's numbered `.sql`
|
|
3
|
+
* migrations. The host passes it to `runMigrationSources` as
|
|
4
|
+
* `{ name: 'reporting', dir: migrationsDir }`.
|
|
5
|
+
*
|
|
6
|
+
* Its own subpath (`@wtfalch/reporting/migrations-dir`) and no other
|
|
7
|
+
* imports: a migration image imports this without the Drizzle, React and
|
|
8
|
+
* Next the main entry pulls in, and a bundler never meets this file's
|
|
9
|
+
* `import.meta.url` through the main entry.
|
|
10
|
+
*/
|
|
11
|
+
export declare const migrationsDir: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
/**
|
|
3
|
+
* Absolute path of the directory holding this package's numbered `.sql`
|
|
4
|
+
* migrations. The host passes it to `runMigrationSources` as
|
|
5
|
+
* `{ name: 'reporting', dir: migrationsDir }`.
|
|
6
|
+
*
|
|
7
|
+
* Its own subpath (`@wtfalch/reporting/migrations-dir`) and no other
|
|
8
|
+
* imports: a migration image imports this without the Drizzle, React and
|
|
9
|
+
* Next the main entry pulls in, and a bundler never meets this file's
|
|
10
|
+
* `import.meta.url` through the main entry.
|
|
11
|
+
*/
|
|
12
|
+
export const migrationsDir = fileURLToPath(new URL('./migrations/', import.meta.url));
|
package/dist/next/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { deviceOf } from '../analytics/schema.js';
|
|
|
3
3
|
import type { Reporting } from '../types.js';
|
|
4
4
|
import { type ClientErrorCollectorOptions } from './client-errors.js';
|
|
5
5
|
import { type EdgeErrorIngestOptions } from './edge-errors.js';
|
|
6
|
+
export { releaseHealthHandler, withRequestCount, type ReleaseHealthOptions, } from './release-health.js';
|
|
6
7
|
export { edgeErrorHandler, type EdgeCaptureOptions, type EdgeErrorReport } from './edge-errors.js';
|
|
7
8
|
/**
|
|
8
9
|
* The Next bindings: everything in the package that knows what a request is.
|
package/dist/next/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createCollector } from '../analytics/collector.js';
|
|
|
6
6
|
import { countryOf, deviceOf } from '../analytics/schema.js';
|
|
7
7
|
import { createClientErrorCollector } from './client-errors.js';
|
|
8
8
|
import { createEdgeErrorIngestCollector } from './edge-errors.js';
|
|
9
|
+
export { releaseHealthHandler, withRequestCount, } from './release-health.js';
|
|
9
10
|
export { edgeErrorHandler } from './edge-errors.js';
|
|
10
11
|
/**
|
|
11
12
|
* The Next bindings: everything in the package that knows what a request is.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Db, Reporting } from '../types.js';
|
|
2
|
+
export interface ReleaseHealthOptions {
|
|
3
|
+
readonly db: Db;
|
|
4
|
+
readonly reporting: Pick<Reporting, 'site' | 'log'>;
|
|
5
|
+
/** Defaults to `process.env.REPORTING_HEALTH_TOKEN`, read on each request. */
|
|
6
|
+
readonly token?: () => string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `GET /api/reporting/release-health?from=<ISO>&to=<ISO>`: the counts foundry
|
|
10
|
+
* judges a new color by. Request and 5xx totals per color over [from, to),
|
|
11
|
+
* and the per-day average of the seven days before `from`. Counts only; no
|
|
12
|
+
* event, error or request content is read. Bearer token from
|
|
13
|
+
* `REPORTING_HEALTH_TOKEN`: unset is 503, missing or wrong is 401.
|
|
14
|
+
*/
|
|
15
|
+
export declare function releaseHealthHandler(options: ReleaseHealthOptions): (request: Request) => Promise<Response>;
|
|
16
|
+
/**
|
|
17
|
+
* Wraps a route handler so each response is counted. A handler that throws is
|
|
18
|
+
* counted as a 500 and the throw goes on. Next's own control-flow throws
|
|
19
|
+
* (`redirect()`, `notFound()` and the like) are counted as a request, not an
|
|
20
|
+
* error, so they cannot push a release toward rollback.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withRequestCount<A extends unknown[]>(reporting: Pick<Reporting, 'requests'>, handler: (...args: A) => Promise<Response> | Response): (...args: A) => Promise<Response>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { and, gte, lt, sql } from 'drizzle-orm';
|
|
3
|
+
import { unstable_rethrow } from 'next/navigation';
|
|
4
|
+
import { connection } from 'next/server';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { reportingRequests } from '../tables.js';
|
|
7
|
+
const DAY_MS = 86_400_000;
|
|
8
|
+
const BASELINE_DAYS = 7;
|
|
9
|
+
const MAX_RANGE_MS = 8 * DAY_MS;
|
|
10
|
+
const iso = z.iso.datetime({ offset: true });
|
|
11
|
+
function tokenMatches(provided, expected) {
|
|
12
|
+
const a = Buffer.from(provided);
|
|
13
|
+
const b = Buffer.from(expected);
|
|
14
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
15
|
+
}
|
|
16
|
+
function json(body, status) {
|
|
17
|
+
return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `GET /api/reporting/release-health?from=<ISO>&to=<ISO>`: the counts foundry
|
|
21
|
+
* judges a new color by. Request and 5xx totals per color over [from, to),
|
|
22
|
+
* and the per-day average of the seven days before `from`. Counts only; no
|
|
23
|
+
* event, error or request content is read. Bearer token from
|
|
24
|
+
* `REPORTING_HEALTH_TOKEN`: unset is 503, missing or wrong is 401.
|
|
25
|
+
*/
|
|
26
|
+
export function releaseHealthHandler(options) {
|
|
27
|
+
const token = options.token ?? (() => process.env.REPORTING_HEALTH_TOKEN);
|
|
28
|
+
const site = options.reporting.site;
|
|
29
|
+
return async (request) => {
|
|
30
|
+
await connection();
|
|
31
|
+
const expected = token();
|
|
32
|
+
if (!expected)
|
|
33
|
+
return json({ error: 'not configured' }, 503);
|
|
34
|
+
const header = request.headers.get('authorization') ?? '';
|
|
35
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
36
|
+
if (!match?.[1] || !tokenMatches(match[1], expected)) {
|
|
37
|
+
return json({ error: 'unauthorized' }, 401);
|
|
38
|
+
}
|
|
39
|
+
const params = new URL(request.url).searchParams;
|
|
40
|
+
const fromRaw = iso.safeParse(params.get('from'));
|
|
41
|
+
const toRaw = iso.safeParse(params.get('to'));
|
|
42
|
+
if (!fromRaw.success || !toRaw.success) {
|
|
43
|
+
return json({ error: 'from and to must be ISO timestamps' }, 400);
|
|
44
|
+
}
|
|
45
|
+
const from = new Date(fromRaw.data);
|
|
46
|
+
const to = new Date(toRaw.data);
|
|
47
|
+
if (!(from.getTime() < to.getTime()) || to.getTime() - from.getTime() > MAX_RANGE_MS) {
|
|
48
|
+
return json({ error: 'from must be before to, at most 8 days apart' }, 400);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const sums = {
|
|
52
|
+
requests: sql `coalesce(sum(${reportingRequests.requests}), 0)`,
|
|
53
|
+
errors: sql `coalesce(sum(${reportingRequests.errors}), 0)`,
|
|
54
|
+
};
|
|
55
|
+
const inSite = sql `${reportingRequests.site} = ${site}`;
|
|
56
|
+
const byColor = await options.db
|
|
57
|
+
.select({ color: reportingRequests.color, ...sums })
|
|
58
|
+
.from(reportingRequests)
|
|
59
|
+
.where(and(inSite, gte(reportingRequests.bucket, from), lt(reportingRequests.bucket, to)))
|
|
60
|
+
.groupBy(reportingRequests.color);
|
|
61
|
+
const baselineFrom = new Date(from.getTime() - BASELINE_DAYS * DAY_MS);
|
|
62
|
+
const [baseline] = await options.db
|
|
63
|
+
.select(sums)
|
|
64
|
+
.from(reportingRequests)
|
|
65
|
+
.where(and(inSite, gte(reportingRequests.bucket, baselineFrom), lt(reportingRequests.bucket, from)));
|
|
66
|
+
const colors = {
|
|
67
|
+
blue: { requests: 0, errors: 0 },
|
|
68
|
+
green: { requests: 0, errors: 0 },
|
|
69
|
+
};
|
|
70
|
+
for (const row of byColor) {
|
|
71
|
+
if (row.color === 'blue' || row.color === 'green') {
|
|
72
|
+
colors[row.color] = { requests: Number(row.requests), errors: Number(row.errors) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return json({
|
|
76
|
+
colors,
|
|
77
|
+
daily: {
|
|
78
|
+
days: BASELINE_DAYS,
|
|
79
|
+
requestsPerDay: Number(baseline?.requests ?? 0) / BASELINE_DAYS,
|
|
80
|
+
errorsPerDay: Number(baseline?.errors ?? 0) / BASELINE_DAYS,
|
|
81
|
+
},
|
|
82
|
+
from: from.toISOString(),
|
|
83
|
+
to: to.toISOString(),
|
|
84
|
+
}, 200);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
options.reporting.log.error({ err: error instanceof Error ? error.name : 'unknown' }, 'reporting: release health read failed');
|
|
88
|
+
return json({ error: 'read failed' }, 500);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// Any status under 500: the request is counted, and not as an error.
|
|
93
|
+
const CONTROL_FLOW_STATUS = 307;
|
|
94
|
+
function isNextControlFlow(error) {
|
|
95
|
+
try {
|
|
96
|
+
unstable_rethrow(error);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// unstable_rethrow throws only for Next's internal control-flow errors.
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Wraps a route handler so each response is counted. A handler that throws is
|
|
106
|
+
* counted as a 500 and the throw goes on. Next's own control-flow throws
|
|
107
|
+
* (`redirect()`, `notFound()` and the like) are counted as a request, not an
|
|
108
|
+
* error, so they cannot push a release toward rollback.
|
|
109
|
+
*/
|
|
110
|
+
export function withRequestCount(reporting, handler) {
|
|
111
|
+
return async (...args) => {
|
|
112
|
+
try {
|
|
113
|
+
const response = await handler(...args);
|
|
114
|
+
reporting.requests.record(response.status);
|
|
115
|
+
return response;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
reporting.requests.record(isNextControlFlow(error) ? CONTROL_FLOW_STATUS : 500);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/react/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReportingErrorRow } from '../tables.js';
|
|
1
|
+
import type { ReportingErrorRow, TenantErrorRow } from '../tables.js';
|
|
2
2
|
/**
|
|
3
3
|
* The operator's error list: a plain table over the rows `errorsPage()`
|
|
4
4
|
* already returned, newest last-seen first — the same props-in split as
|
|
@@ -7,6 +7,6 @@ import type { ReportingErrorRow } from '../tables.js';
|
|
|
7
7
|
* renders the group's summary columns.
|
|
8
8
|
*/
|
|
9
9
|
export declare function ErrorsTable(props: {
|
|
10
|
-
readonly rows: readonly ReportingErrorRow[];
|
|
10
|
+
readonly rows: readonly (ReportingErrorRow | TenantErrorRow)[];
|
|
11
11
|
readonly label?: string;
|
|
12
12
|
}): import("react").JSX.Element;
|
package/dist/react/errors.js
CHANGED
|
@@ -10,7 +10,7 @@ export function ErrorsTable(props) {
|
|
|
10
10
|
if (props.rows.length === 0) {
|
|
11
11
|
return _jsx("p", { "data-reporting-errors-empty": "", children: "No errors captured. All clear." });
|
|
12
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))) })] }));
|
|
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 ?? undefined, 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
14
|
}
|
|
15
15
|
function formatTimestamp(value) {
|
|
16
16
|
return `${value.toISOString().slice(0, 19).replace('T', ' ')} UTC`;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Db, Logger } from './types.js';
|
|
2
|
+
export interface RequestCounterOptions {
|
|
3
|
+
db: Db;
|
|
4
|
+
log: Logger;
|
|
5
|
+
site: string;
|
|
6
|
+
now: () => Date;
|
|
7
|
+
flushEveryMs: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Counts responses in memory and writes one upsert per minute bucket, so a
|
|
11
|
+
* request costs a map increment and the database sees a handful of rows a
|
|
12
|
+
* minute. `errors` is status >= 500. A failed write puts its counts back for
|
|
13
|
+
* the next one; the process dying loses at most `flushEveryMs` of counts.
|
|
14
|
+
*/
|
|
15
|
+
export declare class RequestCounter {
|
|
16
|
+
private readonly o;
|
|
17
|
+
private readonly buckets;
|
|
18
|
+
private timer;
|
|
19
|
+
constructor(o: RequestCounterOptions);
|
|
20
|
+
record(status: number): void;
|
|
21
|
+
flush(): Promise<void>;
|
|
22
|
+
}
|