@wtfalch/reporting 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +56 -0
  2. package/dist/analytics/collector.d.ts +40 -0
  3. package/dist/analytics/collector.js +146 -0
  4. package/dist/analytics/reader.d.ts +63 -0
  5. package/dist/analytics/reader.js +89 -0
  6. package/dist/analytics/schema.d.ts +59 -0
  7. package/dist/analytics/schema.js +126 -0
  8. package/dist/analytics/tasks.d.ts +32 -0
  9. package/dist/analytics/tasks.js +199 -0
  10. package/dist/analytics/write.d.ts +19 -0
  11. package/dist/analytics/write.js +20 -0
  12. package/dist/browser/index.d.ts +53 -0
  13. package/dist/browser/index.js +330 -0
  14. package/dist/errors/capture.d.ts +52 -0
  15. package/dist/errors/capture.js +187 -0
  16. package/dist/errors/fingerprint.d.ts +36 -0
  17. package/dist/errors/fingerprint.js +211 -0
  18. package/dist/errors/reader.d.ts +19 -0
  19. package/dist/errors/reader.js +66 -0
  20. package/dist/errors/redact.d.ts +46 -0
  21. package/dist/errors/redact.js +93 -0
  22. package/dist/errors/tasks.d.ts +16 -0
  23. package/dist/errors/tasks.js +50 -0
  24. package/dist/fake/index.d.ts +5 -1
  25. package/dist/fake/index.js +28 -0
  26. package/dist/housekeeping/index.d.ts +5 -0
  27. package/dist/housekeeping/index.js +18 -6
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +61 -5
  30. package/dist/ingest-limit.d.ts +22 -0
  31. package/dist/ingest-limit.js +39 -0
  32. package/dist/migrations/0002_analytics.sql +350 -0
  33. package/dist/migrations/0003_errors.sql +118 -0
  34. package/dist/next/client-errors.d.ts +46 -0
  35. package/dist/next/client-errors.js +165 -0
  36. package/dist/next/index.d.ts +55 -0
  37. package/dist/next/index.js +72 -0
  38. package/dist/react/charts.d.ts +24 -0
  39. package/dist/react/charts.js +45 -0
  40. package/dist/react/errors.d.ts +12 -0
  41. package/dist/react/errors.js +17 -0
  42. package/dist/react/index.d.ts +21 -0
  43. package/dist/react/index.js +52 -0
  44. package/dist/schema.d.ts +8 -0
  45. package/dist/schema.js +8 -0
  46. package/dist/settings.d.ts +1 -0
  47. package/dist/settings.js +15 -8
  48. package/dist/tables.d.ts +2287 -253
  49. package/dist/tables.js +111 -1
  50. package/dist/types.d.ts +70 -2
  51. package/package.json +19 -1
@@ -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
+ }
@@ -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?: {
@@ -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
- return claimed[0]?.token ?? null;
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 token = await claim(task);
127
- if (!token)
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: options.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
+ }