@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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Task } from '../housekeeping/index.js';
|
|
2
|
+
import type { Db } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Deletes resolved/ignored error rows past the operator's window, up to
|
|
5
|
+
* twenty batches of 5,000 per run, each in its own statement with a bounded
|
|
6
|
+
* timeout, renewing the lease between batches. `reporting_prune_errors`
|
|
7
|
+
* (0003_errors.sql) does the clamping and, critically, never touches a row
|
|
8
|
+
* still in state 'open' regardless of age.
|
|
9
|
+
*
|
|
10
|
+
* Reuses `events.retention_days` rather than adding an `errors.*` setting: a
|
|
11
|
+
* new setting means a migration to reporting_settings and an operator
|
|
12
|
+
* control nobody asked for. One retention window for the whole operational
|
|
13
|
+
* log (events and the errors grouped from them) is the simpler default.
|
|
14
|
+
*/
|
|
15
|
+
export declare const pruneErrors: Task;
|
|
16
|
+
export declare function pruneErrorsBatch(db: Db, days: number, batch: number): Promise<number>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { rowsOf } from '../housekeeping/tasks.js';
|
|
3
|
+
const HOUR = 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Deletes resolved/ignored error rows past the operator's window, up to
|
|
6
|
+
* twenty batches of 5,000 per run, each in its own statement with a bounded
|
|
7
|
+
* timeout, renewing the lease between batches. `reporting_prune_errors`
|
|
8
|
+
* (0003_errors.sql) does the clamping and, critically, never touches a row
|
|
9
|
+
* still in state 'open' regardless of age.
|
|
10
|
+
*
|
|
11
|
+
* Reuses `events.retention_days` rather than adding an `errors.*` setting: a
|
|
12
|
+
* new setting means a migration to reporting_settings and an operator
|
|
13
|
+
* control nobody asked for. One retention window for the whole operational
|
|
14
|
+
* log (events and the errors grouped from them) is the simpler default.
|
|
15
|
+
*/
|
|
16
|
+
export const pruneErrors = {
|
|
17
|
+
name: 'reporting.prune_errors',
|
|
18
|
+
every: HOUR,
|
|
19
|
+
lease: 10 * 60 * 1000,
|
|
20
|
+
retry: 15 * 60 * 1000,
|
|
21
|
+
async run(ctx) {
|
|
22
|
+
const days = ctx.settings['events.retention_days'];
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (let i = 0; i < 20; i += 1) {
|
|
25
|
+
if (ctx.now().getTime() > ctx.deadline.getTime() - 30_000)
|
|
26
|
+
break;
|
|
27
|
+
const n = await pruneErrorsBatch(ctx.db, days, 5000);
|
|
28
|
+
total += n;
|
|
29
|
+
if (n < 5000)
|
|
30
|
+
break;
|
|
31
|
+
if (!(await ctx.renew()))
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (total > 0) {
|
|
35
|
+
ctx.reporting.event({
|
|
36
|
+
kind: 'reporting.pruned_errors',
|
|
37
|
+
message: `pruned ${total} error(s) older than ${days} days`,
|
|
38
|
+
data: { rows: total, days },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export async function pruneErrorsBatch(db, days, batch) {
|
|
44
|
+
return db.transaction(async (tx) => {
|
|
45
|
+
await tx.execute(sql `set local statement_timeout = '30s'`);
|
|
46
|
+
const result = await tx.execute(sql `select reporting_prune_errors(make_interval(days => ${days}::int), ${batch}::int) as n`);
|
|
47
|
+
const [row] = rowsOf(result);
|
|
48
|
+
return Number(row?.n ?? 0);
|
|
49
|
+
});
|
|
50
|
+
}
|
package/dist/fake/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReportingEventRow } from '../tables.js';
|
|
2
|
-
import type { AlertFinding, Logger, Reporting } from '../types.js';
|
|
2
|
+
import type { AlertFinding, Logger, Reporting, TrackInput } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* An in-memory `Reporting` for a host's unit tests: same validation, same
|
|
5
5
|
* shape, no database. `rows` is the table; `alerts` is what `onAlert` would
|
|
@@ -8,6 +8,10 @@ import type { AlertFinding, Logger, Reporting } from '../types.js';
|
|
|
8
8
|
export interface FakeReporting extends Reporting {
|
|
9
9
|
readonly rows: ReportingEventRow[];
|
|
10
10
|
readonly alerts: AlertFinding[];
|
|
11
|
+
/** What `analytics.track` received. */
|
|
12
|
+
readonly tracked: (TrackInput & {
|
|
13
|
+
site: string;
|
|
14
|
+
})[];
|
|
11
15
|
reset(): void;
|
|
12
16
|
}
|
|
13
17
|
export declare function createFakeReporting(opts?: {
|
package/dist/fake/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { alertRow } from '../alerts.js';
|
|
2
|
+
import { createCapture } from '../errors/capture.js';
|
|
2
3
|
import { eventInputSchema } from '../schema.js';
|
|
3
4
|
import { DEFAULT_SETTINGS, RETENTION_BOUNDS } from '../settings.js';
|
|
4
5
|
import { tables } from '../tables.js';
|
|
@@ -8,14 +9,26 @@ export function createFakeReporting(opts = {}) {
|
|
|
8
9
|
const log = opts.log ?? silent;
|
|
9
10
|
const rows = [];
|
|
10
11
|
const alerts = [];
|
|
12
|
+
const tracked = [];
|
|
11
13
|
let settings = { ...DEFAULT_SETTINGS };
|
|
12
14
|
let id = 0;
|
|
15
|
+
// No group table to fake, so the upsert never runs (`defer` is a no-op);
|
|
16
|
+
// the timeline row through `fake.event` is what a host's test asserts on.
|
|
17
|
+
const capture = createCapture({
|
|
18
|
+
db: undefined,
|
|
19
|
+
log,
|
|
20
|
+
site,
|
|
21
|
+
now: () => new Date(),
|
|
22
|
+
defer: () => { },
|
|
23
|
+
event: (input) => fake.event(input),
|
|
24
|
+
});
|
|
13
25
|
const fake = {
|
|
14
26
|
site,
|
|
15
27
|
log,
|
|
16
28
|
tables,
|
|
17
29
|
rows,
|
|
18
30
|
alerts,
|
|
31
|
+
tracked,
|
|
19
32
|
event(input) {
|
|
20
33
|
const v = eventInputSchema.parse(input);
|
|
21
34
|
id += 1;
|
|
@@ -41,6 +54,7 @@ export function createFakeReporting(opts = {}) {
|
|
|
41
54
|
fake.event(alertRow(finding));
|
|
42
55
|
alerts.push(finding);
|
|
43
56
|
},
|
|
57
|
+
captureError: (error, context) => capture(error, context),
|
|
44
58
|
async flush() { },
|
|
45
59
|
events: {
|
|
46
60
|
async page(o = {}) {
|
|
@@ -88,6 +102,20 @@ export function createFakeReporting(opts = {}) {
|
|
|
88
102
|
return { before, after: settings, by, changed };
|
|
89
103
|
},
|
|
90
104
|
},
|
|
105
|
+
analytics: {
|
|
106
|
+
async track(input) {
|
|
107
|
+
tracked.push({ ...input, site });
|
|
108
|
+
},
|
|
109
|
+
async series() {
|
|
110
|
+
return [];
|
|
111
|
+
},
|
|
112
|
+
async weekly() {
|
|
113
|
+
return [];
|
|
114
|
+
},
|
|
115
|
+
async recent() {
|
|
116
|
+
return [];
|
|
117
|
+
},
|
|
118
|
+
},
|
|
91
119
|
stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length }),
|
|
92
120
|
reset() {
|
|
93
121
|
rows.length = 0;
|
|
@@ -40,6 +40,10 @@ export interface TaskContext {
|
|
|
40
40
|
renew(): Promise<boolean>;
|
|
41
41
|
/** The run must finish, or stop scheduling work, by this instant. */
|
|
42
42
|
readonly deadline: Date;
|
|
43
|
+
/** The task's completed-history watermark as claimed, or null. */
|
|
44
|
+
readonly watermark: string | null;
|
|
45
|
+
/** Moves the watermark, fenced by the claim token; false when the lease was lost. Pass a transaction to commit it with the work it vouches for. */
|
|
46
|
+
commitWatermark(value: string, tx?: Db): Promise<boolean>;
|
|
43
47
|
}
|
|
44
48
|
export interface HousekeepingOptions {
|
|
45
49
|
reporting: Reporting;
|
|
@@ -59,3 +63,4 @@ export interface Housekeeping {
|
|
|
59
63
|
}
|
|
60
64
|
export declare function createHousekeeping(options: HousekeepingOptions): Housekeeping;
|
|
61
65
|
export { pruneEvents, retentionLag } from './tasks.js';
|
|
66
|
+
export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
|
|
@@ -51,10 +51,19 @@ export function createHousekeeping(options) {
|
|
|
51
51
|
.where(sql `${reportingTasks.task} = ${task.name}
|
|
52
52
|
and ${reportingTasks.nextDueAt} <= now()
|
|
53
53
|
and (${reportingTasks.leaseExpiresAt} is null or ${reportingTasks.leaseExpiresAt} < now())`)
|
|
54
|
-
.returning({ token: reportingTasks.claimToken });
|
|
55
|
-
|
|
54
|
+
.returning({ token: reportingTasks.claimToken, watermark: reportingTasks.watermark });
|
|
55
|
+
const row = claimed[0];
|
|
56
|
+
return row?.token ? { token: row.token, watermark: row.watermark ?? null } : null;
|
|
56
57
|
});
|
|
57
58
|
}
|
|
59
|
+
async function commitWatermark(task, token, value, handle) {
|
|
60
|
+
const rows = await handle
|
|
61
|
+
.update(reportingTasks)
|
|
62
|
+
.set({ watermark: value })
|
|
63
|
+
.where(sql `${reportingTasks.task} = ${task.name} and ${reportingTasks.claimToken} = ${token}`)
|
|
64
|
+
.returning({ task: reportingTasks.task });
|
|
65
|
+
return rows.length === 1;
|
|
66
|
+
}
|
|
58
67
|
async function renew(task, token) {
|
|
59
68
|
const rows = await db
|
|
60
69
|
.update(reportingTasks)
|
|
@@ -78,7 +87,7 @@ export function createHousekeeping(options) {
|
|
|
78
87
|
})
|
|
79
88
|
.where(sql `${reportingTasks.task} = ${task.name} and ${reportingTasks.claimToken} = ${token}`);
|
|
80
89
|
}
|
|
81
|
-
async function runClaimed(task, token, settings) {
|
|
90
|
+
async function runClaimed(task, token, watermark, settings) {
|
|
82
91
|
const started = now();
|
|
83
92
|
const deadline = new Date(started.getTime() + task.lease);
|
|
84
93
|
let timer;
|
|
@@ -95,6 +104,8 @@ export function createHousekeeping(options) {
|
|
|
95
104
|
now,
|
|
96
105
|
renew: () => renew(task, token),
|
|
97
106
|
deadline,
|
|
107
|
+
watermark,
|
|
108
|
+
commitWatermark: (value, tx) => commitWatermark(task, token, value, tx ?? db),
|
|
98
109
|
}),
|
|
99
110
|
timeout,
|
|
100
111
|
]);
|
|
@@ -123,10 +134,10 @@ export function createHousekeeping(options) {
|
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
async function runOne(task, settings) {
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
137
|
+
const claimed = await claim(task);
|
|
138
|
+
if (!claimed)
|
|
128
139
|
return 'skipped';
|
|
129
|
-
return runClaimed(task, token, settings);
|
|
140
|
+
return runClaimed(task, claimed.token, claimed.watermark, settings);
|
|
130
141
|
}
|
|
131
142
|
async function tickNow() {
|
|
132
143
|
await ensureRows();
|
|
@@ -197,3 +208,4 @@ export function createHousekeeping(options) {
|
|
|
197
208
|
};
|
|
198
209
|
}
|
|
199
210
|
export { pruneEvents, retentionLag } from './tasks.js';
|
|
211
|
+
export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,11 @@ export * from './schema.js';
|
|
|
3
3
|
export * from './tables.js';
|
|
4
4
|
export * from './types.js';
|
|
5
5
|
export { alertKind, projectDetail } from './alerts.js';
|
|
6
|
-
export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
|
|
6
|
+
export { CONSENT_MODES, DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
|
|
7
|
+
export * from './analytics/schema.js';
|
|
8
|
+
export { createCollector, type CollectorOptions, type Sites } from './analytics/collector.js';
|
|
9
|
+
export { analyticsRecent, analyticsSeries, analyticsWeekly, hideSmallGroups, type Grain, type RecentEvent, type RecentOptions, type SeriesOptions, type SeriesPoint, type WeeklyPoint, } from './analytics/reader.js';
|
|
10
|
+
export type { AnalyticsRow } from './analytics/write.js';
|
|
7
11
|
/**
|
|
8
12
|
* The host's one instance, built once over its drizzle handle and its
|
|
9
13
|
* logger. Framework-neutral: `defer` is how the host says "after this unit of
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { alertRow } from './alerts.js';
|
|
2
|
+
import { analyticsRecent, analyticsSeries, analyticsWeekly } from './analytics/reader.js';
|
|
3
|
+
import { ANALYTICS_NAME_PATTERN, normalisePath, propsSchema } from './analytics/schema.js';
|
|
4
|
+
import { insertAnalytics } from './analytics/write.js';
|
|
5
|
+
import { createCapture } from './errors/capture.js';
|
|
2
6
|
import { eventsPage } from './reader.js';
|
|
3
7
|
import { siteSchema } from './schema.js';
|
|
4
8
|
import { getSettings, setSettings } from './settings.js';
|
|
@@ -8,7 +12,10 @@ export * from './schema.js';
|
|
|
8
12
|
export * from './tables.js';
|
|
9
13
|
export * from './types.js';
|
|
10
14
|
export { alertKind, projectDetail } from './alerts.js';
|
|
11
|
-
export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
|
|
15
|
+
export { CONSENT_MODES, DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
|
|
16
|
+
export * from './analytics/schema.js';
|
|
17
|
+
export { createCollector } from './analytics/collector.js';
|
|
18
|
+
export { analyticsRecent, analyticsSeries, analyticsWeekly, hideSmallGroups, } from './analytics/reader.js';
|
|
12
19
|
function modeFromEnv() {
|
|
13
20
|
const env = process.env.NODE_ENV;
|
|
14
21
|
return env === 'development' || env === 'test' ? env : 'production';
|
|
@@ -23,25 +30,38 @@ export function createReporting(options) {
|
|
|
23
30
|
const site = siteSchema.parse(options.site);
|
|
24
31
|
const now = options.now ?? (() => new Date());
|
|
25
32
|
const mode = options.mode ?? modeFromEnv();
|
|
33
|
+
const defer = options.defer ??
|
|
34
|
+
((fn) => {
|
|
35
|
+
setTimeout(() => void fn(), 0);
|
|
36
|
+
});
|
|
26
37
|
const writer = new Writer({
|
|
27
38
|
db: options.db,
|
|
28
39
|
log: options.log,
|
|
29
40
|
site,
|
|
30
41
|
mode,
|
|
31
42
|
now,
|
|
32
|
-
defer
|
|
33
|
-
((fn) => {
|
|
34
|
-
setTimeout(() => void fn(), 0);
|
|
35
|
-
}),
|
|
43
|
+
defer,
|
|
36
44
|
queueLimit: options.queueLimit ?? 1000,
|
|
37
45
|
batchSize: options.batchSize ?? 500,
|
|
38
46
|
flushEveryMs: options.flushEveryMs ?? 1000,
|
|
39
47
|
});
|
|
48
|
+
// Same db, log, defer, now and site as the writer: one instance, one
|
|
49
|
+
// upsert path, no second connection or timer to keep in sync.
|
|
50
|
+
const capture = createCapture({
|
|
51
|
+
db: options.db,
|
|
52
|
+
log: options.log,
|
|
53
|
+
site,
|
|
54
|
+
now,
|
|
55
|
+
defer,
|
|
56
|
+
event: (input) => writer.event(input),
|
|
57
|
+
release: process.env.REPORTING_RELEASE ?? null,
|
|
58
|
+
});
|
|
40
59
|
const reporting = {
|
|
41
60
|
site,
|
|
42
61
|
log: options.log,
|
|
43
62
|
tables,
|
|
44
63
|
event: (input) => writer.event(input),
|
|
64
|
+
captureError: (error, context) => capture(error, context),
|
|
45
65
|
alert(finding) {
|
|
46
66
|
writer.event(alertRow(finding));
|
|
47
67
|
if (!options.onAlert)
|
|
@@ -75,6 +95,42 @@ export function createReporting(options) {
|
|
|
75
95
|
return change;
|
|
76
96
|
},
|
|
77
97
|
},
|
|
98
|
+
analytics: {
|
|
99
|
+
async track(input) {
|
|
100
|
+
try {
|
|
101
|
+
if (!ANALYTICS_NAME_PATTERN.test(input.name)) {
|
|
102
|
+
throw new Error(`analytics.track: "${input.name}" is not a name`);
|
|
103
|
+
}
|
|
104
|
+
const props = propsSchema.parse(input.props ?? {});
|
|
105
|
+
const at = input.occurredAt ?? now();
|
|
106
|
+
await insertAnalytics(options.db, [
|
|
107
|
+
{
|
|
108
|
+
occurredAt: at,
|
|
109
|
+
receivedAt: now(),
|
|
110
|
+
site,
|
|
111
|
+
tenantId: input.tenantId ?? null,
|
|
112
|
+
visitorId: input.visitorId ?? null,
|
|
113
|
+
sessionId: input.sessionId ?? null,
|
|
114
|
+
userId: input.userId ?? null,
|
|
115
|
+
name: input.name,
|
|
116
|
+
path: (options.normalisePath ?? normalisePath)(input.path ?? '/'),
|
|
117
|
+
referrerHost: null,
|
|
118
|
+
device: input.device ?? 'unknown',
|
|
119
|
+
country: input.country ?? null,
|
|
120
|
+
props,
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (mode !== 'production')
|
|
126
|
+
throw error;
|
|
127
|
+
options.log.error({ err: describe(error), name: input.name }, 'reporting: track failed');
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
series: (opts) => analyticsSeries(options.db, opts),
|
|
131
|
+
weekly: (opts) => analyticsWeekly(options.db, opts),
|
|
132
|
+
recent: (opts) => analyticsRecent(options.db, opts),
|
|
133
|
+
},
|
|
78
134
|
stats: () => writer.stats(),
|
|
79
135
|
};
|
|
80
136
|
return reporting;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token bucket both public ingest routes share.
|
|
3
|
+
*
|
|
4
|
+
* `./next`'s analytics collector and its client-error collector are the only
|
|
5
|
+
* two routes in this package a host mounts unauthenticated, and both write to
|
|
6
|
+
* the database on a stranger's say-so. They had one limiter between them: the
|
|
7
|
+
* analytics one. The error route went without until it was noticed that
|
|
8
|
+
* `reporting_errors` never prunes an open row (migrations/0003_errors.sql),
|
|
9
|
+
* so an unbounded public write lands in a table nothing ages out — the two
|
|
10
|
+
* decisions are safe apart and not together.
|
|
11
|
+
*
|
|
12
|
+
* Per process, not per deploy: several containers each keep their own, so the
|
|
13
|
+
* real ceiling is this times the container count. Enough to bound abuse,
|
|
14
|
+
* never to meter.
|
|
15
|
+
*/
|
|
16
|
+
export declare class Buckets {
|
|
17
|
+
private readonly perMinute;
|
|
18
|
+
private readonly now;
|
|
19
|
+
private readonly buckets;
|
|
20
|
+
constructor(perMinute: number, now: () => Date);
|
|
21
|
+
take(key: string, n: number): boolean;
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token bucket both public ingest routes share.
|
|
3
|
+
*
|
|
4
|
+
* `./next`'s analytics collector and its client-error collector are the only
|
|
5
|
+
* two routes in this package a host mounts unauthenticated, and both write to
|
|
6
|
+
* the database on a stranger's say-so. They had one limiter between them: the
|
|
7
|
+
* analytics one. The error route went without until it was noticed that
|
|
8
|
+
* `reporting_errors` never prunes an open row (migrations/0003_errors.sql),
|
|
9
|
+
* so an unbounded public write lands in a table nothing ages out — the two
|
|
10
|
+
* decisions are safe apart and not together.
|
|
11
|
+
*
|
|
12
|
+
* Per process, not per deploy: several containers each keep their own, so the
|
|
13
|
+
* real ceiling is this times the container count. Enough to bound abuse,
|
|
14
|
+
* never to meter.
|
|
15
|
+
*/
|
|
16
|
+
export class Buckets {
|
|
17
|
+
perMinute;
|
|
18
|
+
now;
|
|
19
|
+
buckets = new Map();
|
|
20
|
+
constructor(perMinute, now) {
|
|
21
|
+
this.perMinute = perMinute;
|
|
22
|
+
this.now = now;
|
|
23
|
+
}
|
|
24
|
+
take(key, n) {
|
|
25
|
+
const t = this.now().getTime();
|
|
26
|
+
const b = this.buckets.get(key) ?? { tokens: this.perMinute, at: t };
|
|
27
|
+
b.tokens = Math.min(this.perMinute, b.tokens + ((t - b.at) / 60_000) * this.perMinute);
|
|
28
|
+
b.at = t;
|
|
29
|
+
if (b.tokens < n) {
|
|
30
|
+
this.buckets.set(key, b);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
b.tokens -= n;
|
|
34
|
+
this.buckets.set(key, b);
|
|
35
|
+
if (this.buckets.size > 10_000)
|
|
36
|
+
this.buckets.clear();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|