@wtfalch/reporting 0.5.0 → 0.7.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 CHANGED
@@ -3,9 +3,12 @@
3
3
  The estate's operational event log and the housekeeping tick behind it: a
4
4
  writer that never fails a request, a claim protocol that needs no scheduler,
5
5
  and the readers an operator opens. Per-app Postgres, a host-supplied logger,
6
- a framework-neutral core and a Next entry. The design and its decisions are
7
- `docs/plans/reporting.md` and `docs/plans/reporting/core.md` in
8
- `wtfalch/app-template`.
6
+ a framework-neutral core and a Next entry. The design and its decisions were
7
+ originally `docs/plans/reporting.md` and `docs/plans/reporting/core.md` in
8
+ `wtfalch/app-template`, which was archived 2026-09-24 and replaced by
9
+ `foundry`; those docs are a historical record only. The design history for
10
+ everything past 0.2.0 lives in this repo's own `docs/plans/errors.md` and
11
+ `CHANGELOG.md`.
9
12
 
10
13
  Three records make up reporting on the estate. This package holds the
11
14
  **events** (what the system did: jobs, drains, alerts, an integration saying
@@ -15,27 +18,104 @@ row is transactional and its actor is unforgeable; an event row is best
15
18
  effort. The two share column names so a reader can walk between them, and
16
19
  nothing else.
17
20
 
21
+ ## Status
22
+
23
+ Published: yes, v0.5.1, 2026-09-28
24
+
18
25
  ## Install
19
26
 
20
27
  ```sh
21
- pnpm add @wtfalch/reporting
22
- pnpm exec reporting-migrations # copies migrations/*.sql into drizzle/ as the next numbers
28
+ pnpm add @wtfalch/reporting @wtfalch/db
29
+ ```
30
+
31
+ The package ships SQL and never connects or migrates. The host installs
32
+ `@wtfalch/db`, applies the SQL with the owner credential into a schema of
33
+ its own, then makes the runtime role. The runtime `searchPath` and
34
+ `ensureRuntimeRole({ schemas })` must both cover that schema. The SQL names
35
+ no schema and no role: its `SECURITY DEFINER` functions use `set search_path
36
+ from current`, so they find their tables in whatever schema the host
37
+ migrated into. Migrate with `@wtfalch/db` 0.5.2 or later: 0.5.0 and 0.5.1
38
+ let a temp table shadow a table inside a `SECURITY DEFINER` function, and
39
+ 0.5.2 puts `pg_temp` last on the runner's `search_path`.
40
+
41
+ ```ts
42
+ import { migrationsDir as reportingMigrations } from '@wtfalch/reporting/migrations-dir';
43
+ import { runMigrationSources } from '@wtfalch/db/migrate';
44
+ import { ensureRuntimeRole } from '@wtfalch/db/runtime-role';
45
+
46
+ await runMigrationSources({
47
+ url: ownerUrl,
48
+ schema: 'myservice', // created if missing; the tables land here, never in public
49
+ sources: [
50
+ { name: 'reporting', dir: reportingMigrations }, // before the host's own
51
+ { name: 'app', dir: 'drizzle' },
52
+ ],
53
+ });
54
+
55
+ await ensureRuntimeRole({
56
+ ownerUrl,
57
+ runtimeUrl,
58
+ schemas: ['myservice'],
59
+ appendOnly: ['reporting_events', 'reporting_analytics'], // insert and read only
60
+ readOnly: ['reporting_analytics_daily', 'reporting_analytics_weekly'], // the rollups: written only by the functions
61
+ noDelete: [ // insert and update, never delete (needs @wtfalch/db 0.5.2 or later)
62
+ 'reporting_tasks',
63
+ 'reporting_settings',
64
+ 'reporting_errors',
65
+ 'reporting_tenant_settings',
66
+ 'reporting_error_tenants',
67
+ ],
68
+ grants: [ // the seven security definer functions, schema-qualified
69
+ 'myservice.reporting_prune_events(interval, integer)',
70
+ 'myservice.reporting_rollup_day(date)',
71
+ 'myservice.reporting_rollup_week(date)',
72
+ 'myservice.reporting_prune_analytics(interval, integer, date)',
73
+ 'myservice.reporting_erase_person(text)',
74
+ 'myservice.reporting_prune_errors(interval, integer)',
75
+ 'myservice.reporting_prune_open_errors(integer, integer)',
76
+ ],
77
+ });
23
78
  ```
24
79
 
25
- The copied migration creates `reporting_events`, `reporting_tasks` and
26
- `reporting_settings`, a `SECURITY DEFINER` prune function, and, when a role
27
- named `<database>_rt` exists, revokes UPDATE, DELETE and TRUNCATE on events
28
- from it. That is the estate's runtime-role convention; a host without the
29
- role gets the tables and the function and no revokes take effect. Your
30
- migrate script applies the file; this package never connects to apply
31
- anything.
80
+ Run `ensureRuntimeRole` after every migration run: the lists apply to the
81
+ tables that exist at that moment. `grants` entries are spliced into SQL, so
82
+ they are trusted text. Without `noDelete` the role can delete from those five
83
+ tables. These are the privileges the
84
+ package's own tests check, on a real Postgres, in a named schema.
85
+
86
+ At boot, check the role over the runtime connection, whose `searchPath`
87
+ includes the schema:
88
+
89
+ ```ts
90
+ import { assertRuntimeRole } from '@wtfalch/db/runtime-role';
91
+
92
+ await assertRuntimeRole(connection.database, {
93
+ appendOnly: ['myservice.reporting_events', 'myservice.reporting_analytics'],
94
+ readOnly: ['myservice.reporting_analytics_daily', 'myservice.reporting_analytics_weekly'],
95
+ noDelete: [
96
+ 'myservice.reporting_tasks',
97
+ 'myservice.reporting_settings',
98
+ 'myservice.reporting_errors',
99
+ 'myservice.reporting_tenant_settings',
100
+ 'myservice.reporting_error_tenants',
101
+ ],
102
+ });
103
+ ```
104
+
105
+ The runtime handle is the native Drizzle one: pass `withDrizzle(runtime, {
106
+ schema: tables }).orm` (or `tx.orm`) from `@wtfalch/db/drizzle`.
107
+
108
+ Without `@wtfalch/db`, `reporting-migrations` still copies
109
+ `migrations/*.sql` into `drizzle/` as the next numbers and the host applies
110
+ them itself. Prefer `runMigrationSources`: the copy path has no schema and no
111
+ runtime-role step, so the host must grant and revoke by hand.
32
112
 
33
113
  ## Bind it once
34
114
 
35
115
  ```ts
36
116
  // src/lib/reporting/core.ts, the one file that imports your raw handle
37
117
  import { createReporting } from '@wtfalch/reporting';
38
- import { createHousekeeping, pruneEvents, retentionLag } from '@wtfalch/reporting/housekeeping';
118
+ import { createHousekeeping, pruneErrors, pruneEvents, retentionLag } from '@wtfalch/reporting/housekeeping';
39
119
  import { deferWithAfter } from '@wtfalch/reporting/next';
40
120
 
41
121
  export const reporting = createReporting({
@@ -48,6 +128,7 @@ export const reporting = createReporting({
48
128
 
49
129
  export const housekeeping = createHousekeeping({ reporting });
50
130
  housekeeping.register(pruneEvents);
131
+ housekeeping.register(pruneErrors);
51
132
  housekeeping.register(retentionLag);
52
133
  ```
53
134
 
@@ -136,6 +217,24 @@ every row regardless.
136
217
 
137
218
  ## Analytics (0.2.0)
138
219
 
220
+ **Topology, today:** this package is a library, not a host. `createReporting({
221
+ db, ... })` binds to whichever Postgres the calling app already has, and
222
+ every table below lives in that one app's own database. `tenantId` scopes a
223
+ read to one organisation *within* that single app's database — it does not
224
+ let a console outside that app read anything. There is no cross-app or
225
+ cross-company read: one company's own Boule sees only the rows its own
226
+ process wrote.
227
+
228
+ **Topology, target (settled 2026-09-28, planned after launch):** one shared
229
+ reporting collector, org-scoped like `@wtfalch/files` and `@wtfalch/ai` —
230
+ every app's `reporting` calls point at that one shared store instead of a
231
+ database of its own, row-level scoping keeps one company's rows out of
232
+ another's reads, Archon reads totals across every company, and each
233
+ company's own Boule reads only its own organisation's slice. Not built yet:
234
+ today's shape is the per-app library described above, and the shared-host
235
+ change (or a lighter "point every app's `reporting` at one app's database"
236
+ approach) is an open architecture question for whoever picks it up next.
237
+
139
238
  Three tables and two writers. `reporting_analytics` holds raw rows for a
140
239
  window (`analytics.retention_days`, default 90); `reporting_analytics_daily`
141
240
  and `reporting_analytics_weekly` hold counts with no identifier in any column
@@ -1,9 +1,9 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
2
  import { ERROR_RUNTIMES, KIND_PATTERN, LIMITS } from '../schema.js';
3
- import { reportingErrors } from '../tables.js';
3
+ import { reportingErrorTenants, reportingErrors } from '../tables.js';
4
4
  import { describe } from '../writer.js';
5
5
  import { errorKind, fingerprint } from './fingerprint.js';
6
- import { heldSecrets, redactText } from './redact.js';
6
+ import { heldSecrets, redactPii, redactText } from './redact.js';
7
7
  const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g;
8
8
  const INVALID_KIND_CHARS = /[^a-z0-9_]+/g;
9
9
  /** camelCase/PascalCase/anything, folded down to what `KIND_PATTERN` allows. */
@@ -51,6 +51,8 @@ function messageOf(error, kind) {
51
51
  function stackOf(error) {
52
52
  return error instanceof Error && typeof error.stack === 'string' ? error.stack : null;
53
53
  }
54
+ /** Extra characters kept before redaction so a secret at the size limit is seen whole. */
55
+ const SLACK = 1024;
54
56
  /** Keeps the top of the text — the start, where the throw site and its callers are — and drops the rest. */
55
57
  function truncateEnd(text, max) {
56
58
  return text.length > max ? text.slice(0, max) : text;
@@ -101,7 +103,7 @@ function normaliseRuntime(runtime) {
101
103
  * share one transaction so the lock from the first is still held for the
102
104
  * second.
103
105
  */
104
- async function upsertGroup(db, site, s) {
106
+ async function upsertGroup(db, site, s, log) {
105
107
  return db.transaction(async (tx) => {
106
108
  const prior = await tx
107
109
  .select({ state: reportingErrors.state })
@@ -131,7 +133,7 @@ async function upsertGroup(db, site, s) {
131
133
  target: reportingErrors.fingerprint,
132
134
  set: {
133
135
  occurrences: sql `${reportingErrors.occurrences} + 1`,
134
- lastSeenAt: s.at,
136
+ lastSeenAt: sql `greatest(${reportingErrors.lastSeenAt}, ${s.at.toISOString()}::timestamptz)`,
135
137
  message: s.message,
136
138
  stack: s.stack,
137
139
  tenantId: s.tenantId,
@@ -147,6 +149,33 @@ async function upsertGroup(db, site, s) {
147
149
  // false when it took the on-conflict-update path -- the standard,
148
150
  // race-free way to tell the two apart from an upsert's own result.
149
151
  .returning({ created: sql `(xmax = 0)` });
152
+ if (s.tenantId) {
153
+ // In a savepoint and fail-soft: a host that has not applied 0008 yet
154
+ // must still get the group row. The tenant row is the lesser loss.
155
+ try {
156
+ await tx.transaction(async (sp) => {
157
+ await sp
158
+ .insert(reportingErrorTenants)
159
+ .values({
160
+ fingerprint: s.fingerprint,
161
+ tenantId: s.tenantId,
162
+ occurrences: 1,
163
+ firstSeenAt: s.at,
164
+ lastSeenAt: s.at,
165
+ })
166
+ .onConflictDoUpdate({
167
+ target: [reportingErrorTenants.fingerprint, reportingErrorTenants.tenantId],
168
+ set: {
169
+ occurrences: sql `${reportingErrorTenants.occurrences} + 1`,
170
+ lastSeenAt: sql `greatest(${reportingErrorTenants.lastSeenAt}, ${s.at.toISOString()}::timestamptz)`,
171
+ },
172
+ });
173
+ });
174
+ }
175
+ catch (err) {
176
+ log.warn({ err: describe(err), fingerprint: s.fingerprint }, 'reporting: tenant row not written; is migration 0008 applied?');
177
+ }
178
+ }
150
179
  if (written[0]?.created)
151
180
  return 'created';
152
181
  return priorState === 'resolved' || priorState === 'ignored' ? 'reopened' : 'unchanged';
@@ -171,9 +200,15 @@ export function createCapture(o) {
171
200
  try {
172
201
  const kind = resolveKind(error, context.kind);
173
202
  const secrets = heldSecrets(process.env, o.redactEnvVars);
174
- const message = truncateEnd(redactText(messageOf(error, kind), secrets), LIMITS.message);
203
+ // Bound the text before redacting it: the patterns must never see an
204
+ // uncapped, attacker-sized string. The cut keeps slack past the limit so
205
+ // a secret straddling the limit is redacted whole, then the final cut
206
+ // applies the real limit.
207
+ const message = truncateEnd(redactPii(redactText(truncateEnd(messageOf(error, kind), LIMITS.message + SLACK), secrets)), LIMITS.message);
175
208
  const rawStack = stackOf(error);
176
- const stack = rawStack === null ? null : truncateEnd(redactText(rawStack, secrets), LIMITS.stack);
209
+ const stack = rawStack === null
210
+ ? null
211
+ : truncateEnd(redactPii(redactText(truncateEnd(rawStack, LIMITS.stack + SLACK), secrets)), LIMITS.stack);
177
212
  const fp = fingerprint({ kind, message, stack });
178
213
  const runtime = normaliseRuntime(context.runtime);
179
214
  // A tenant id that is not a uuid would be refused by the event row's
@@ -208,7 +243,7 @@ export function createCapture(o) {
208
243
  at,
209
244
  };
210
245
  try {
211
- o.defer(() => upsertGroup(o.db, o.site, sample)
246
+ o.defer(() => upsertGroup(o.db, o.site, sample, o.log)
212
247
  .then((outcome) => {
213
248
  if (outcome === 'unchanged' || !o.alert)
214
249
  return;
@@ -1,5 +1,5 @@
1
1
  import type { ErrorState } from '../schema.js';
2
- import type { ReportingErrorRow } from '../tables.js';
2
+ import type { ReportingErrorRow, TenantErrorRow } from '../tables.js';
3
3
  import type { Actor, Db, ErrorsPage, ErrorsPageOptions } from '../types.js';
4
4
  /**
5
5
  * Newest first, keyset-paged on `(last_seen_at, fingerprint)`, the table's
@@ -11,9 +11,28 @@ import type { Actor, Db, ErrorsPage, ErrorsPageOptions } from '../types.js';
11
11
  * for its scale. No permission is applied here: the host gates, and this
12
12
  * returns rows as stored.
13
13
  */
14
+ export declare function errorsPage(db: Db, opts?: ErrorsPageOptions & {
15
+ readonly tenantId?: undefined;
16
+ }): Promise<ErrorsPage<ReportingErrorRow>>;
17
+ export declare function errorsPage(db: Db, opts: ErrorsPageOptions & {
18
+ readonly tenantId: string;
19
+ }): Promise<ErrorsPage<TenantErrorRow>>;
14
20
  export declare function errorsPage(db: Db, opts?: ErrorsPageOptions): Promise<ErrorsPage>;
15
- /** One row by fingerprint, or null when it is unknown. */
16
- export declare function errorDetail(db: Db, fingerprint: string): Promise<ReportingErrorRow | null>;
21
+ /**
22
+ * One row by fingerprint, or null when it is unknown. With `tenantId`, null
23
+ * too when that tenant never hit it, and the row is a `TenantErrorRow`: the
24
+ * counts and times are the tenant's own and the shared text and triage state
25
+ * are null, as in `errorsPage`. A present `tenantId` that is not a uuid throws.
26
+ */
27
+ export declare function errorDetail(db: Db, fingerprint: string, opts?: {
28
+ readonly tenantId?: undefined;
29
+ }): Promise<ReportingErrorRow | null>;
30
+ export declare function errorDetail(db: Db, fingerprint: string, opts: {
31
+ readonly tenantId: string;
32
+ }): Promise<TenantErrorRow | null>;
33
+ export declare function errorDetail(db: Db, fingerprint: string, opts?: {
34
+ readonly tenantId?: string;
35
+ }): Promise<ReportingErrorRow | TenantErrorRow | null>;
17
36
  /**
18
37
  * Moves an error's state. 'resolved' and 'ignored' stamp `resolved_at` and
19
38
  * `resolved_by` with this instant and who did it; moving back to 'open'
@@ -1,32 +1,75 @@
1
1
  import { and, desc, eq, ilike, lt, or, sql } from 'drizzle-orm';
2
- import { reportingErrors } from '../tables.js';
2
+ import { reportingErrorTenants, reportingErrors } from '../tables.js';
3
3
  /** Postgres's default LIKE/ILIKE escape is backslash; escaping the pattern's own three special characters is what makes a search term match itself literally rather than as a wildcard pattern. */
4
4
  function likePattern(term) {
5
5
  return `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
6
6
  }
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
8
  /**
8
- * Newest first, keyset-paged on `(last_seen_at, fingerprint)`, the table's
9
- * index. Every filter is an equality on an indexed column, except `search`:
10
- * a case-insensitive substring match on `message` or `stack`, for triaging
11
- * an incident by grepping the group list rather than raw rows by hand (gap
12
- * issue #8). Unindexed by design -- a trigram index would need the
13
- * pg_trgm extension, an operational cost this triage tool does not carry
14
- * for its scale. No permission is applied here: the host gates, and this
15
- * returns rows as stored.
9
+ * Scope is decided by whether the key is present, not by truthiness: an empty
10
+ * string or an explicit `undefined` from a host bug must not fall through to
11
+ * the unscoped read of every tenant's data, so a present key that is not a
12
+ * uuid throws. An absent key is unscoped.
16
13
  */
14
+ function tenantScope(opts) {
15
+ if (!('tenantId' in opts))
16
+ return undefined;
17
+ const { tenantId } = opts;
18
+ if (typeof tenantId !== 'string' || !UUID.test(tenantId)) {
19
+ throw new Error('tenantId must be a uuid when given; refusing to fall back to an unscoped read');
20
+ }
21
+ return tenantId;
22
+ }
23
+ /**
24
+ * The columns a tenant may see of a shared group. The text and request facts
25
+ * of the group belong to whichever tenant hit it last, and its triage state is
26
+ * one operator's call for every tenant, so those are null.
27
+ */
28
+ function tenantColumns() {
29
+ return {
30
+ fingerprint: reportingErrors.fingerprint,
31
+ site: reportingErrors.site,
32
+ environment: reportingErrors.environment,
33
+ kind: reportingErrors.kind,
34
+ message: sql `null`.as('message'),
35
+ stack: sql `null`.as('stack'),
36
+ runtime: sql `null`.as('runtime'),
37
+ release: sql `null`.as('release'),
38
+ requestId: sql `null`.as('request_id'),
39
+ resolvedBy: sql `null`.as('resolved_by'),
40
+ state: sql `null`.as('state'),
41
+ resolvedAt: sql `null`.as('resolved_at'),
42
+ tenantId: reportingErrorTenants.tenantId,
43
+ occurrences: reportingErrorTenants.occurrences,
44
+ firstSeenAt: reportingErrorTenants.firstSeenAt,
45
+ lastSeenAt: reportingErrorTenants.lastSeenAt,
46
+ };
47
+ }
17
48
  export async function errorsPage(db, opts = {}) {
49
+ const tenantId = tenantScope(opts);
18
50
  const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
51
+ // With a tenant, the page is that tenant's own view: its rows join the
52
+ // group and supply the counts and times, so ordering and paging follow the
53
+ // tenant's last occurrence, not anyone else's.
54
+ const seen = tenantId !== undefined ? reportingErrorTenants.lastSeenAt : reportingErrors.lastSeenAt;
19
55
  const conditions = [];
20
56
  if (opts.site)
21
57
  conditions.push(eq(reportingErrors.site, opts.site));
22
- if (opts.state)
58
+ // `state` is shared operator triage, so in tenant scope it is ignored: a
59
+ // filter on it would show one tenant whether another reopened the group.
60
+ if (opts.state && tenantId === undefined)
23
61
  conditions.push(eq(reportingErrors.state, opts.state));
24
- if (opts.runtime)
62
+ // `runtime` and the text are the latest sample from any tenant, so a tenant
63
+ // scope neither filters on the first nor searches the rest: either would be
64
+ // an oracle on another tenant's data. Search matches `kind` there.
65
+ if (opts.runtime && tenantId === undefined)
25
66
  conditions.push(eq(reportingErrors.runtime, opts.runtime));
26
67
  const search = opts.search?.trim().slice(0, 200);
27
68
  if (search) {
28
69
  const pattern = likePattern(search);
29
- const clause = or(ilike(reportingErrors.message, pattern), ilike(reportingErrors.stack, pattern));
70
+ const clause = tenantId !== undefined
71
+ ? ilike(reportingErrors.kind, pattern)
72
+ : or(ilike(reportingErrors.message, pattern), ilike(reportingErrors.stack, pattern));
30
73
  if (clause)
31
74
  conditions.push(clause);
32
75
  }
@@ -34,16 +77,26 @@ export async function errorsPage(db, opts = {}) {
34
77
  conditions.push(eq(reportingErrors.environment, opts.environment));
35
78
  if (opts.after) {
36
79
  const { lastSeenAt, fingerprint } = opts.after;
37
- const keyset = or(lt(reportingErrors.lastSeenAt, lastSeenAt), and(eq(reportingErrors.lastSeenAt, lastSeenAt), lt(reportingErrors.fingerprint, fingerprint)));
80
+ const keyset = or(lt(seen, lastSeenAt), and(eq(seen, lastSeenAt), lt(reportingErrors.fingerprint, fingerprint)));
38
81
  if (keyset)
39
82
  conditions.push(keyset);
40
83
  }
41
- const rows = await db
42
- .select()
43
- .from(reportingErrors)
44
- .where(conditions.length > 0 ? and(...conditions) : undefined)
45
- .orderBy(desc(reportingErrors.lastSeenAt), desc(reportingErrors.fingerprint))
46
- .limit(limit + 1);
84
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
85
+ const order = [desc(seen), desc(reportingErrors.fingerprint)];
86
+ const rows = tenantId !== undefined
87
+ ? await db
88
+ .select(tenantColumns())
89
+ .from(reportingErrors)
90
+ .innerJoin(reportingErrorTenants, and(eq(reportingErrorTenants.fingerprint, reportingErrors.fingerprint), eq(reportingErrorTenants.tenantId, tenantId)))
91
+ .where(where)
92
+ .orderBy(...order)
93
+ .limit(limit + 1)
94
+ : await db
95
+ .select()
96
+ .from(reportingErrors)
97
+ .where(where)
98
+ .orderBy(...order)
99
+ .limit(limit + 1);
47
100
  const items = rows.slice(0, limit);
48
101
  const last = items.at(-1);
49
102
  return {
@@ -53,8 +106,17 @@ export async function errorsPage(db, opts = {}) {
53
106
  : null,
54
107
  };
55
108
  }
56
- /** One row by fingerprint, or null when it is unknown. */
57
- export async function errorDetail(db, fingerprint) {
109
+ export async function errorDetail(db, fingerprint, opts = {}) {
110
+ const tenantId = tenantScope(opts);
111
+ if (tenantId !== undefined) {
112
+ const scoped = await db
113
+ .select(tenantColumns())
114
+ .from(reportingErrors)
115
+ .innerJoin(reportingErrorTenants, and(eq(reportingErrorTenants.fingerprint, reportingErrors.fingerprint), eq(reportingErrorTenants.tenantId, tenantId)))
116
+ .where(eq(reportingErrors.fingerprint, fingerprint))
117
+ .limit(1);
118
+ return scoped[0] ?? null;
119
+ }
58
120
  const rows = await db
59
121
  .select()
60
122
  .from(reportingErrors)
@@ -42,6 +42,7 @@ export declare function heldSecrets(env: Readonly<Record<string, string | undefi
42
42
  * than wrapping each in an object just to hand it to `scrub`.
43
43
  */
44
44
  export declare function redactText(text: string, secrets: readonly string[]): string;
45
+ export declare function redactPii(text: string): string;
45
46
  /**
46
47
  * The same structure with every held secret replaced.
47
48
  *
@@ -68,6 +68,69 @@ export function redactText(text, secrets) {
68
68
  }
69
69
  return out;
70
70
  }
71
+ /**
72
+ * Pattern-based PII redaction for a captured `message` or `stack`.
73
+ *
74
+ * `redactText` only knows the exact strings the host holds. A user's email
75
+ * address or a bearer token inside an error message is not one of them, yet
76
+ * it would be stored verbatim, kept until pruned and readable by anyone who
77
+ * can open the errors page. This covers the common shapes by pattern: cheap,
78
+ * and with false negatives (a bare opaque token with no label is not caught).
79
+ * A string that only looks like a secret and is not one, such as a version
80
+ * like `lodash@4.17.20`, is left alone.
81
+ */
82
+ const PII_PATTERNS = [
83
+ // URL userinfo: `scheme://user:pass@host` keeps scheme and user, drops the
84
+ // password (also `scheme://:pass@host`). Before the email rule, which would
85
+ // otherwise take `pass@host` and leave the user behind a wrong label.
86
+ [/\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:@/]{0,64}:)[^\s@/]{1,256}@/gi, '$1[redacted: password]@'],
87
+ // Every quantifier is bounded (RFC 5321 limits: 64-char local part, 63-char
88
+ // labels), so a long run of `a.a.a.` with no `@` costs a fixed amount per
89
+ // start position rather than a rescan of the rest of the string.
90
+ [
91
+ /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,8}\.[A-Za-z]{2,24}\b/g,
92
+ '[redacted: email]',
93
+ ],
94
+ // A PEM private key block; an unterminated one (a truncated stack) runs to the end.
95
+ [
96
+ /-----BEGIN [A-Z ]{0,30}PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]{0,30}PRIVATE KEY-----|$)/g,
97
+ '[redacted: private key]',
98
+ ],
99
+ // The JSON form of an Authorization header, whatever the value's length.
100
+ [/("authorization"\s*:\s*)"(?:[^"\\]|\\.)*"/gi, '$1"[redacted: token]"'],
101
+ // A Cookie or Set-Cookie header's value, to the end of the line.
102
+ [/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, '$1[redacted: cookie]'],
103
+ // A JSON "cookie" / "set-cookie" value.
104
+ [/("(?:set-)?cookie"\s*:\s*)"(?:[^"\\]|\\.){0,2048}"/gi, '$1"[redacted: cookie]"'],
105
+ // A labelled secret in `=`, `:`, JSON (`"password":"x"`) or `%3D` form,
106
+ // including `x-api-key:` headers. A quoted value is taken whole.
107
+ [
108
+ /((?:password|passwd|token|secret|api[_-]?key|apikey|client_secret|access_token|refresh_token)["']?\s*(?::|=|%3D)\s*)(?:"(?:[^"\\]|\\.){0,512}"|'[^']{0,512}'|[^\s&;,'"}]+)/gi,
109
+ '$1[redacted: secret]',
110
+ ],
111
+ // JWT: three base64url segments, the first two starting with an encoded `{"`.
112
+ // Linear: a start cannot sit inside a base64url run (the lookbehind), and
113
+ // each segment is capped, so a hostile `-eyJ-eyJ...` costs a fixed amount.
114
+ [
115
+ /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,2048}\.eyJ[A-Za-z0-9_-]{5,2048}\.[A-Za-z0-9_-]{0,2048}/g,
116
+ '[redacted: token]',
117
+ ],
118
+ // An Authorization header's value, including its scheme.
119
+ [/\b(authorization\s*[:=]\s*)(?:(?:bearer|basic|token)\s+)?[^\s,;'"]+/gi, '$1[redacted: token]'],
120
+ // A scheme followed by a credential, anywhere.
121
+ [/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 [redacted: token]'],
122
+ // Well-known prefixed token formats.
123
+ [
124
+ /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[0-9A-Z]{16}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}|AIza[0-9A-Za-z_-]{30,})\b/g,
125
+ '[redacted: token]',
126
+ ],
127
+ ];
128
+ export function redactPii(text) {
129
+ let out = text;
130
+ for (const [pattern, replacement] of PII_PATTERNS)
131
+ out = out.replace(pattern, replacement);
132
+ return out;
133
+ }
71
134
  /**
72
135
  * The same structure with every held secret replaced.
73
136
  *
@@ -14,3 +14,18 @@ import type { Db } from '../types.js';
14
14
  */
15
15
  export declare const pruneErrors: Task;
16
16
  export declare function pruneErrorsBatch(db: Db, days: number, batch: number): Promise<number>;
17
+ /**
18
+ * The most open groups one site keeps. `pruneErrors` never deletes an open
19
+ * group, and the public ingest route lets anyone create one per distinct
20
+ * message, so without this ceiling `reporting_errors` grows without bound.
21
+ * Generous for a real app (a few thousand distinct live bugs is already an
22
+ * incident), tight enough that a flood cannot fill the disk.
23
+ */
24
+ export declare const MAX_OPEN_ERRORS_PER_SITE = 5000;
25
+ /**
26
+ * Evicts the least recently seen open groups beyond `MAX_OPEN_ERRORS_PER_SITE`
27
+ * per site (0007_errors_open_cap.sql). Needs that migration; a host that has
28
+ * not applied it sees this one task fail and `pruneErrors` unaffected.
29
+ */
30
+ export declare const pruneOpenErrors: Task;
31
+ export declare function pruneOpenErrorsBatch(db: Db, cap: number, batch: number): Promise<number>;
@@ -48,3 +48,50 @@ export async function pruneErrorsBatch(db, days, batch) {
48
48
  return Number(row?.n ?? 0);
49
49
  });
50
50
  }
51
+ /**
52
+ * The most open groups one site keeps. `pruneErrors` never deletes an open
53
+ * group, and the public ingest route lets anyone create one per distinct
54
+ * message, so without this ceiling `reporting_errors` grows without bound.
55
+ * Generous for a real app (a few thousand distinct live bugs is already an
56
+ * incident), tight enough that a flood cannot fill the disk.
57
+ */
58
+ export const MAX_OPEN_ERRORS_PER_SITE = 5000;
59
+ /**
60
+ * Evicts the least recently seen open groups beyond `MAX_OPEN_ERRORS_PER_SITE`
61
+ * per site (0007_errors_open_cap.sql). Needs that migration; a host that has
62
+ * not applied it sees this one task fail and `pruneErrors` unaffected.
63
+ */
64
+ export const pruneOpenErrors = {
65
+ name: 'reporting.prune_open_errors',
66
+ every: HOUR,
67
+ lease: 10 * 60 * 1000,
68
+ retry: 15 * 60 * 1000,
69
+ async run(ctx) {
70
+ let total = 0;
71
+ for (let i = 0; i < 20; i += 1) {
72
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 30_000)
73
+ break;
74
+ const n = await pruneOpenErrorsBatch(ctx.db, MAX_OPEN_ERRORS_PER_SITE, 5000);
75
+ total += n;
76
+ if (n < 5000)
77
+ break;
78
+ if (!(await ctx.renew()))
79
+ break;
80
+ }
81
+ if (total > 0) {
82
+ ctx.reporting.event({
83
+ kind: 'reporting.pruned_open_errors',
84
+ message: `evicted ${total} open error group(s) beyond the per-site cap`,
85
+ data: { rows: total, cap: MAX_OPEN_ERRORS_PER_SITE },
86
+ });
87
+ }
88
+ },
89
+ };
90
+ export async function pruneOpenErrorsBatch(db, cap, batch) {
91
+ return db.transaction(async (tx) => {
92
+ await tx.execute(sql `set local statement_timeout = '30s'`);
93
+ const result = await tx.execute(sql `select reporting_prune_open_errors(${cap}::int, ${batch}::int) as n`);
94
+ const [row] = rowsOf(result);
95
+ return Number(row?.n ?? 0);
96
+ });
97
+ }
@@ -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';
@@ -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
- -- Estate-shaped: the DO block at the end assumes the runtime-role convention
9
- -- (a role named <database>_rt that owns nothing and serves the app). A host
10
- -- without that role gets the tables and the function and no revokes take
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 = pg_catalog, public
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 default privileges granted the
140
- -- rest at create time; the revokes here win because they run later in the
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 = pg_catalog, public
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 = pg_catalog, public
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 = pg_catalog, public
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 = pg_catalog, public
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
- do $$
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
- -- Estate-shaped: the DO block at the end assumes the runtime-role convention
11
- -- (a role named <database>_rt that owns nothing and serves the app). A host
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 = pg_catalog, public
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 grant below is execute only, same shape as reporting_prune_events.
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 = pg_catalog, public
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 = pg_catalog, public
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,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));
@@ -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;
@@ -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`;
package/dist/tables.d.ts CHANGED
@@ -1633,6 +1633,99 @@ export declare const reportingErrors: import("drizzle-orm/pg-core").PgTableWithC
1633
1633
  };
1634
1634
  dialect: "pg";
1635
1635
  }>;
1636
+ /** One row per (fingerprint, tenant): that tenant's own occurrences of a shared error group; mirrored from migrations/0008_error_tenants.sql. */
1637
+ export declare const reportingErrorTenants: import("drizzle-orm/pg-core").PgTableWithColumns<{
1638
+ name: "reporting_error_tenants";
1639
+ schema: undefined;
1640
+ columns: {
1641
+ fingerprint: import("drizzle-orm/pg-core").PgColumn<{
1642
+ name: "fingerprint";
1643
+ tableName: "reporting_error_tenants";
1644
+ dataType: "string";
1645
+ columnType: "PgText";
1646
+ data: string;
1647
+ driverParam: string;
1648
+ notNull: true;
1649
+ hasDefault: false;
1650
+ isPrimaryKey: false;
1651
+ isAutoincrement: false;
1652
+ hasRuntimeDefault: false;
1653
+ enumValues: [string, ...string[]];
1654
+ baseColumn: never;
1655
+ identity: undefined;
1656
+ generated: undefined;
1657
+ }, {}, {}>;
1658
+ tenantId: import("drizzle-orm/pg-core").PgColumn<{
1659
+ name: "tenant_id";
1660
+ tableName: "reporting_error_tenants";
1661
+ dataType: "string";
1662
+ columnType: "PgUUID";
1663
+ data: string;
1664
+ driverParam: string;
1665
+ notNull: true;
1666
+ hasDefault: false;
1667
+ isPrimaryKey: false;
1668
+ isAutoincrement: false;
1669
+ hasRuntimeDefault: false;
1670
+ enumValues: undefined;
1671
+ baseColumn: never;
1672
+ identity: undefined;
1673
+ generated: undefined;
1674
+ }, {}, {}>;
1675
+ occurrences: import("drizzle-orm/pg-core").PgColumn<{
1676
+ name: "occurrences";
1677
+ tableName: "reporting_error_tenants";
1678
+ dataType: "number";
1679
+ columnType: "PgBigInt53";
1680
+ data: number;
1681
+ driverParam: string | number;
1682
+ notNull: true;
1683
+ hasDefault: true;
1684
+ isPrimaryKey: false;
1685
+ isAutoincrement: false;
1686
+ hasRuntimeDefault: false;
1687
+ enumValues: undefined;
1688
+ baseColumn: never;
1689
+ identity: undefined;
1690
+ generated: undefined;
1691
+ }, {}, {}>;
1692
+ firstSeenAt: import("drizzle-orm/pg-core").PgColumn<{
1693
+ name: "first_seen_at";
1694
+ tableName: "reporting_error_tenants";
1695
+ dataType: "date";
1696
+ columnType: "PgTimestamp";
1697
+ data: Date;
1698
+ driverParam: string;
1699
+ notNull: true;
1700
+ hasDefault: false;
1701
+ isPrimaryKey: false;
1702
+ isAutoincrement: false;
1703
+ hasRuntimeDefault: false;
1704
+ enumValues: undefined;
1705
+ baseColumn: never;
1706
+ identity: undefined;
1707
+ generated: undefined;
1708
+ }, {}, {}>;
1709
+ lastSeenAt: import("drizzle-orm/pg-core").PgColumn<{
1710
+ name: "last_seen_at";
1711
+ tableName: "reporting_error_tenants";
1712
+ dataType: "date";
1713
+ columnType: "PgTimestamp";
1714
+ data: Date;
1715
+ driverParam: string;
1716
+ notNull: true;
1717
+ hasDefault: false;
1718
+ isPrimaryKey: false;
1719
+ isAutoincrement: false;
1720
+ hasRuntimeDefault: false;
1721
+ enumValues: undefined;
1722
+ baseColumn: never;
1723
+ identity: undefined;
1724
+ generated: undefined;
1725
+ }, {}, {}>;
1726
+ };
1727
+ dialect: "pg";
1728
+ }>;
1636
1729
  export type ReportingEventRow = typeof reportingEvents.$inferSelect;
1637
1730
  export type ReportingAnalyticsRow = typeof reportingAnalytics.$inferSelect;
1638
1731
  export type ReportingAnalyticsDailyRow = typeof reportingAnalyticsDaily.$inferSelect;
@@ -1641,6 +1734,24 @@ export type ReportingTaskRow = typeof reportingTasks.$inferSelect;
1641
1734
  export type ReportingSettingRow = typeof reportingSettings.$inferSelect;
1642
1735
  export type ReportingTenantSettingRow = typeof reportingTenantSettings.$inferSelect;
1643
1736
  export type ReportingErrorRow = typeof reportingErrors.$inferSelect;
1737
+ /**
1738
+ * A group as a tenant sees it: the shared group's text and request facts come
1739
+ * from whichever tenant hit it last, and its triage state (`state`,
1740
+ * `resolvedAt`) is one operator's call for all tenants, so they are null here.
1741
+ * Counts, times and
1742
+ * `tenantId` are the tenant's own (reporting_error_tenants).
1743
+ */
1744
+ export type TenantErrorRow = Omit<ReportingErrorRow, 'message' | 'stack' | 'runtime' | 'release' | 'requestId' | 'resolvedBy' | 'state' | 'resolvedAt'> & {
1745
+ readonly message: null;
1746
+ readonly stack: null;
1747
+ readonly runtime: null;
1748
+ readonly release: null;
1749
+ readonly requestId: null;
1750
+ readonly resolvedBy: null;
1751
+ /** Triage state is shared across tenants, so a tenant cannot see it. */
1752
+ readonly state: null;
1753
+ readonly resolvedAt: null;
1754
+ };
1644
1755
  export declare const tables: {
1645
1756
  events: import("drizzle-orm/pg-core").PgTableWithColumns<{
1646
1757
  name: "reporting_events";
@@ -3265,4 +3376,96 @@ export declare const tables: {
3265
3376
  };
3266
3377
  dialect: "pg";
3267
3378
  }>;
3379
+ errorTenants: import("drizzle-orm/pg-core").PgTableWithColumns<{
3380
+ name: "reporting_error_tenants";
3381
+ schema: undefined;
3382
+ columns: {
3383
+ fingerprint: import("drizzle-orm/pg-core").PgColumn<{
3384
+ name: "fingerprint";
3385
+ tableName: "reporting_error_tenants";
3386
+ dataType: "string";
3387
+ columnType: "PgText";
3388
+ data: string;
3389
+ driverParam: string;
3390
+ notNull: true;
3391
+ hasDefault: false;
3392
+ isPrimaryKey: false;
3393
+ isAutoincrement: false;
3394
+ hasRuntimeDefault: false;
3395
+ enumValues: [string, ...string[]];
3396
+ baseColumn: never;
3397
+ identity: undefined;
3398
+ generated: undefined;
3399
+ }, {}, {}>;
3400
+ tenantId: import("drizzle-orm/pg-core").PgColumn<{
3401
+ name: "tenant_id";
3402
+ tableName: "reporting_error_tenants";
3403
+ dataType: "string";
3404
+ columnType: "PgUUID";
3405
+ data: string;
3406
+ driverParam: string;
3407
+ notNull: true;
3408
+ hasDefault: false;
3409
+ isPrimaryKey: false;
3410
+ isAutoincrement: false;
3411
+ hasRuntimeDefault: false;
3412
+ enumValues: undefined;
3413
+ baseColumn: never;
3414
+ identity: undefined;
3415
+ generated: undefined;
3416
+ }, {}, {}>;
3417
+ occurrences: import("drizzle-orm/pg-core").PgColumn<{
3418
+ name: "occurrences";
3419
+ tableName: "reporting_error_tenants";
3420
+ dataType: "number";
3421
+ columnType: "PgBigInt53";
3422
+ data: number;
3423
+ driverParam: string | number;
3424
+ notNull: true;
3425
+ hasDefault: true;
3426
+ isPrimaryKey: false;
3427
+ isAutoincrement: false;
3428
+ hasRuntimeDefault: false;
3429
+ enumValues: undefined;
3430
+ baseColumn: never;
3431
+ identity: undefined;
3432
+ generated: undefined;
3433
+ }, {}, {}>;
3434
+ firstSeenAt: import("drizzle-orm/pg-core").PgColumn<{
3435
+ name: "first_seen_at";
3436
+ tableName: "reporting_error_tenants";
3437
+ dataType: "date";
3438
+ columnType: "PgTimestamp";
3439
+ data: Date;
3440
+ driverParam: string;
3441
+ notNull: true;
3442
+ hasDefault: false;
3443
+ isPrimaryKey: false;
3444
+ isAutoincrement: false;
3445
+ hasRuntimeDefault: false;
3446
+ enumValues: undefined;
3447
+ baseColumn: never;
3448
+ identity: undefined;
3449
+ generated: undefined;
3450
+ }, {}, {}>;
3451
+ lastSeenAt: import("drizzle-orm/pg-core").PgColumn<{
3452
+ name: "last_seen_at";
3453
+ tableName: "reporting_error_tenants";
3454
+ dataType: "date";
3455
+ columnType: "PgTimestamp";
3456
+ data: Date;
3457
+ driverParam: string;
3458
+ notNull: true;
3459
+ hasDefault: false;
3460
+ isPrimaryKey: false;
3461
+ isAutoincrement: false;
3462
+ hasRuntimeDefault: false;
3463
+ enumValues: undefined;
3464
+ baseColumn: never;
3465
+ identity: undefined;
3466
+ generated: undefined;
3467
+ }, {}, {}>;
3468
+ };
3469
+ dialect: "pg";
3470
+ }>;
3268
3471
  };
package/dist/tables.js CHANGED
@@ -176,6 +176,17 @@ export const reportingErrors = pgTable('reporting_errors', {
176
176
  .where(sql `${t.state} = 'open'`),
177
177
  index('reporting_errors_state_idx').on(t.state, t.lastSeenAt.desc()),
178
178
  ]);
179
+ /** One row per (fingerprint, tenant): that tenant's own occurrences of a shared error group; mirrored from migrations/0008_error_tenants.sql. */
180
+ export const reportingErrorTenants = pgTable('reporting_error_tenants', {
181
+ fingerprint: text('fingerprint').notNull(),
182
+ tenantId: uuid('tenant_id').notNull(),
183
+ occurrences: bigint('occurrences', { mode: 'number' }).notNull().default(1),
184
+ firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(),
185
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(),
186
+ }, (t) => [
187
+ primaryKey({ name: 'reporting_error_tenants_pkey', columns: [t.fingerprint, t.tenantId] }),
188
+ index('reporting_error_tenants_tenant_idx').on(t.tenantId, t.lastSeenAt.desc()),
189
+ ]);
179
190
  export const tables = {
180
191
  events: reportingEvents,
181
192
  tasks: reportingTasks,
@@ -185,4 +196,5 @@ export const tables = {
185
196
  analyticsDaily: reportingAnalyticsDaily,
186
197
  analyticsWeekly: reportingAnalyticsWeekly,
187
198
  errors: reportingErrors,
199
+ errorTenants: reportingErrorTenants,
188
200
  };
package/dist/types.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { RecentEvent, RecentOptions, SeriesOptions, SeriesPoint, WeeklyPoin
3
3
  import type { Device } from './analytics/schema.js';
4
4
  import type { Actor, ErrorRuntime, ErrorState, EventInput, FlatData, Level } from './schema.js';
5
5
  import type { TenantRetentionKey } from './settings.js';
6
- import type { ReportingErrorRow, ReportingEventRow, tables } from './tables.js';
6
+ import type { ReportingErrorRow, ReportingEventRow, TenantErrorRow, tables } from './tables.js';
7
7
  /**
8
8
  * The host's drizzle handle, whatever driver it runs on. postgres-js in the
9
9
  * apps, PGlite in this package's own tests; the queries here use nothing
@@ -111,12 +111,14 @@ export interface ErrorsPageOptions {
111
111
  readonly site?: string;
112
112
  readonly state?: ErrorState;
113
113
  readonly runtime?: ErrorRuntime;
114
- /** A case-insensitive substring match against `message` or `stack`, for triaging an incident by grepping the group list. Capped at 200 characters. */
114
+ /** A case-insensitive substring match against `message` or `stack`, for triaging an incident by grepping the group list. Capped at 200 characters. With `tenantId`, it matches `kind` only: the shared message and stack are not the tenant's to search. */
115
115
  readonly search?: string;
116
116
  readonly environment?: string;
117
+ /** Only groups this tenant hit, as `TenantErrorRow`s: `occurrences`, `firstSeenAt`, `lastSeenAt` and `tenantId` come from the tenant's own record (migration 0008), and `message`, `stack`, `runtime`, `release`, `requestId`, `resolvedBy`, `state` and `resolvedAt` are null, because the shared group holds them from whichever tenant hit it last. `state` is ignored as a filter in tenant scope. Scope is decided by presence: an absent key is unscoped, any present value, `undefined` included, must be a uuid or the call throws. */
118
+ readonly tenantId?: string;
117
119
  }
118
- export interface ErrorsPage {
119
- readonly items: readonly ReportingErrorRow[];
120
+ export interface ErrorsPage<Row = ReportingErrorRow | TenantErrorRow> {
121
+ readonly items: readonly Row[];
120
122
  readonly next: {
121
123
  readonly lastSeenAt: Date;
122
124
  readonly fingerprint: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/reporting",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The estate's operational event log and housekeeping tick: a writer that never fails a request, a claim protocol that needs no scheduler, and the readers an operator opens. Per-app Postgres, host-supplied logger, framework-neutral core with a Next entry.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,6 +46,10 @@
46
46
  "types": "./dist/react/errors.d.ts",
47
47
  "default": "./dist/react/errors.js"
48
48
  },
49
+ "./migrations-dir": {
50
+ "types": "./dist/migrations-dir.d.ts",
51
+ "default": "./dist/migrations-dir.js"
52
+ },
49
53
  "./migrations/*.sql": "./dist/migrations/*.sql",
50
54
  "./package.json": "./package.json"
51
55
  },
@@ -63,7 +67,7 @@
63
67
  "test": "vitest run"
64
68
  },
65
69
  "peerDependencies": {
66
- "drizzle-orm": ">=0.39.0",
70
+ "drizzle-orm": ">=0.39.3 <1.0.0",
67
71
  "next": ">=16.0.0",
68
72
  "react": ">=19.0.0",
69
73
  "zod": "^4.0.0"
@@ -77,9 +81,9 @@
77
81
  "@electric-sql/pglite": "^0.5.8",
78
82
  "@types/node": "^22",
79
83
  "@types/react": "^19",
80
- "drizzle-orm": "^0.39.3",
84
+ "@wtfalch/db": "0.5.2",
85
+ "drizzle-orm": "0.39.3",
81
86
  "next": "^16.2.4",
82
- "postgres": "^3.4.5",
83
87
  "react": "^19",
84
88
  "react-dom": "^19",
85
89
  "typescript": "^5.9.0",