@wtfalch/reporting 0.5.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +115 -15
  2. package/dist/analytics/write.js +3 -0
  3. package/dist/deploy.d.ts +11 -0
  4. package/dist/deploy.js +13 -0
  5. package/dist/errors/capture.js +46 -7
  6. package/dist/errors/reader.d.ts +22 -3
  7. package/dist/errors/reader.js +84 -21
  8. package/dist/errors/redact.d.ts +1 -0
  9. package/dist/errors/redact.js +63 -0
  10. package/dist/errors/tasks.d.ts +15 -0
  11. package/dist/errors/tasks.js +47 -0
  12. package/dist/housekeeping/index.d.ts +2 -0
  13. package/dist/housekeeping/index.js +2 -0
  14. package/dist/index.js +13 -1
  15. package/dist/migrations/0001_reporting.sql +6 -20
  16. package/dist/migrations/0002_analytics.sql +6 -20
  17. package/dist/migrations/0003_errors.sql +4 -16
  18. package/dist/migrations/0006_tenant_settings.sql +3 -21
  19. package/dist/migrations/0007_errors_open_cap.sql +42 -0
  20. package/dist/migrations/0008_error_tenants.sql +35 -0
  21. package/dist/migrations/0009_release_color.sql +33 -0
  22. package/dist/migrations/0010_requests.sql +27 -0
  23. package/dist/migrations-dir.d.ts +11 -0
  24. package/dist/migrations-dir.js +12 -0
  25. package/dist/next/index.d.ts +1 -0
  26. package/dist/next/index.js +1 -0
  27. package/dist/next/release-health.d.ts +22 -0
  28. package/dist/next/release-health.js +122 -0
  29. package/dist/react/errors.d.ts +2 -2
  30. package/dist/react/errors.js +1 -1
  31. package/dist/requests.d.ts +22 -0
  32. package/dist/requests.js +72 -0
  33. package/dist/tables.d.ts +609 -16
  34. package/dist/tables.js +32 -0
  35. package/dist/types.d.ts +12 -4
  36. package/dist/writer.js +2 -0
  37. package/package.json +8 -8
  38. package/dist/fake/index.d.ts +0 -22
  39. package/dist/fake/index.js +0 -157
package/README.md CHANGED
@@ -25,24 +25,99 @@ Published: yes, v0.5.1, 2026-09-28
25
25
  ## Install
26
26
 
27
27
  ```sh
28
- pnpm add @wtfalch/reporting
29
- pnpm exec reporting-migrations # copies migrations/*.sql into drizzle/ as the next numbers
28
+ pnpm add @wtfalch/reporting @wtfalch/db
30
29
  ```
31
30
 
32
- The copied migration creates `reporting_events`, `reporting_tasks` and
33
- `reporting_settings`, a `SECURITY DEFINER` prune function, and, when a role
34
- named `<database>_rt` exists, revokes UPDATE, DELETE and TRUNCATE on events
35
- from it. That is the estate's runtime-role convention; a host without the
36
- role gets the tables and the function and no revokes take effect. Your
37
- migrate script applies the file; this package never connects to apply
38
- anything.
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
+ 'reporting_requests',
68
+ ],
69
+ grants: [ // the seven security definer functions, schema-qualified
70
+ 'myservice.reporting_prune_events(interval, integer)',
71
+ 'myservice.reporting_rollup_day(date)',
72
+ 'myservice.reporting_rollup_week(date)',
73
+ 'myservice.reporting_prune_analytics(interval, integer, date)',
74
+ 'myservice.reporting_erase_person(text)',
75
+ 'myservice.reporting_prune_errors(interval, integer)',
76
+ 'myservice.reporting_prune_open_errors(integer, integer)',
77
+ ],
78
+ });
79
+ ```
80
+
81
+ Run `ensureRuntimeRole` after every migration run: the lists apply to the
82
+ tables that exist at that moment. `grants` entries are spliced into SQL, so
83
+ they are trusted text. Without `noDelete` the role can delete from those six
84
+ tables. These are the privileges the
85
+ package's own tests check, on a real Postgres, in a named schema.
86
+
87
+ At boot, check the role over the runtime connection, whose `searchPath`
88
+ includes the schema:
89
+
90
+ ```ts
91
+ import { assertRuntimeRole } from '@wtfalch/db/runtime-role';
92
+
93
+ await assertRuntimeRole(connection.database, {
94
+ appendOnly: ['myservice.reporting_events', 'myservice.reporting_analytics'],
95
+ readOnly: ['myservice.reporting_analytics_daily', 'myservice.reporting_analytics_weekly'],
96
+ noDelete: [
97
+ 'myservice.reporting_tasks',
98
+ 'myservice.reporting_settings',
99
+ 'myservice.reporting_errors',
100
+ 'myservice.reporting_tenant_settings',
101
+ 'myservice.reporting_error_tenants',
102
+ 'myservice.reporting_requests',
103
+ ],
104
+ });
105
+ ```
106
+
107
+ The runtime handle is the native Drizzle one: pass `withDrizzle(runtime, {
108
+ schema: tables }).orm` (or `tx.orm`) from `@wtfalch/db/drizzle`.
109
+
110
+ Without `@wtfalch/db`, `reporting-migrations` still copies
111
+ `migrations/*.sql` into `drizzle/` as the next numbers and the host applies
112
+ them itself. Prefer `runMigrationSources`: the copy path has no schema and no
113
+ runtime-role step, so the host must grant and revoke by hand.
39
114
 
40
115
  ## Bind it once
41
116
 
42
117
  ```ts
43
118
  // src/lib/reporting/core.ts, the one file that imports your raw handle
44
119
  import { createReporting } from '@wtfalch/reporting';
45
- import { createHousekeeping, pruneEvents, retentionLag } from '@wtfalch/reporting/housekeeping';
120
+ import { createHousekeeping, pruneErrors, pruneEvents, retentionLag } from '@wtfalch/reporting/housekeeping';
46
121
  import { deferWithAfter } from '@wtfalch/reporting/next';
47
122
 
48
123
  export const reporting = createReporting({
@@ -55,6 +130,7 @@ export const reporting = createReporting({
55
130
 
56
131
  export const housekeeping = createHousekeeping({ reporting });
57
132
  housekeeping.register(pruneEvents);
133
+ housekeeping.register(pruneErrors);
58
134
  housekeeping.register(retentionLag);
59
135
  ```
60
136
 
@@ -131,6 +207,31 @@ edit can shorten history to a week and no further. Pass `audit` to
131
207
  `createReporting` to have every change handed to your audit ledger after it
132
208
  commits.
133
209
 
210
+ ## Release health (0.8.0)
211
+
212
+ With `REPORTING_RELEASE` (an image digest) and `REPORTING_COLOR` (`blue` or
213
+ `green`) set, every event, analytics and error row carries both. Count each
214
+ response and mount the read foundry judges a new color by:
215
+
216
+ ```ts
217
+ // app/api/reporting/release-health/route.ts
218
+ import { releaseHealthHandler } from '@wtfalch/reporting/next';
219
+ export const GET = releaseHealthHandler({ db, reporting });
220
+
221
+ // any route handler: counted per minute, color and release (status >= 500 is an error)
222
+ import { withRequestCount } from '@wtfalch/reporting/next';
223
+ export const POST = withRequestCount(reporting, async (req) => Response.json({ ok: true }));
224
+ ```
225
+
226
+ `reporting.requests.record(status)` is the same count for a host that has no
227
+ route handler to wrap. Counts sit in memory and are written once per minute
228
+ bucket (every 10 seconds by default; `reporting.flush()` writes them now). The
229
+ read needs `Authorization: Bearer $REPORTING_HEALTH_TOKEN`: 401 for a missing or
230
+ wrong token, 503 when the variable is unset. It answers `from` and `to` (ISO,
231
+ `from` before `to`, at most 8 days; otherwise 400) with request and 5xx counts
232
+ per color and the per-day average of the 7 days before `from`. Counts only; no
233
+ event content. Rows of `reporting_requests` are not pruned.
234
+
134
235
  ## Shutdown
135
236
 
136
237
  ```ts
@@ -217,11 +318,10 @@ the rollups hold nothing to erase. Call it in the host's erasure transaction.
217
318
 
218
319
  ## Tests
219
320
 
220
- ```ts
221
- import { createFakeReporting } from '@wtfalch/reporting/fake';
222
- ```
223
-
224
- Same validation, no database; `fake.rows` is the table.
321
+ No package ships a stand-in for a service. `createFakeReporting` (same
322
+ validation, no database, `fake.rows` is the table) is this package's own
323
+ unexported test double, under `src/test/`, for its own test files only -- a
324
+ host writes its own double, or runs this package against a real database.
225
325
 
226
326
  ## What this package never does
227
327
 
@@ -1,7 +1,9 @@
1
+ import { deployTags } from '../deploy.js';
1
2
  import { reportingAnalytics } from '../tables.js';
2
3
  export async function insertAnalytics(db, rows) {
3
4
  if (rows.length === 0)
4
5
  return;
6
+ const tags = deployTags();
5
7
  await db.insert(reportingAnalytics).values(rows.map((r) => ({
6
8
  occurredAt: r.occurredAt,
7
9
  receivedAt: r.receivedAt,
@@ -16,5 +18,6 @@ export async function insertAnalytics(db, rows) {
16
18
  device: r.device,
17
19
  country: r.country,
18
20
  props: r.props,
21
+ ...tags,
19
22
  })));
20
23
  }
@@ -0,0 +1,11 @@
1
+ export type Color = 'blue' | 'green';
2
+ export interface DeployTags {
3
+ readonly release: string | null;
4
+ readonly color: Color | null;
5
+ }
6
+ /**
7
+ * The release and color the platform gave this container
8
+ * (`REPORTING_RELEASE`, `REPORTING_COLOR`). A color that is not blue or green
9
+ * is null, so a bad value cannot fail a write on the column's CHECK.
10
+ */
11
+ export declare function deployTags(env?: NodeJS.ProcessEnv): DeployTags;
package/dist/deploy.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The release and color the platform gave this container
3
+ * (`REPORTING_RELEASE`, `REPORTING_COLOR`). A color that is not blue or green
4
+ * is null, so a bad value cannot fail a write on the column's CHECK.
5
+ */
6
+ export function deployTags(env = process.env) {
7
+ const color = env.REPORTING_COLOR;
8
+ const release = env.REPORTING_RELEASE?.slice(0, 128);
9
+ return {
10
+ release: release ? release : null,
11
+ color: color === 'blue' || color === 'green' ? color : null,
12
+ };
13
+ }
@@ -1,9 +1,10 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
+ import { deployTags } from '../deploy.js';
2
3
  import { ERROR_RUNTIMES, KIND_PATTERN, LIMITS } from '../schema.js';
3
- import { reportingErrors } from '../tables.js';
4
+ import { reportingErrorTenants, reportingErrors } from '../tables.js';
4
5
  import { describe } from '../writer.js';
5
6
  import { errorKind, fingerprint } from './fingerprint.js';
6
- import { heldSecrets, redactText } from './redact.js';
7
+ import { heldSecrets, redactPii, redactText } from './redact.js';
7
8
  const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g;
8
9
  const INVALID_KIND_CHARS = /[^a-z0-9_]+/g;
9
10
  /** camelCase/PascalCase/anything, folded down to what `KIND_PATTERN` allows. */
@@ -51,6 +52,8 @@ function messageOf(error, kind) {
51
52
  function stackOf(error) {
52
53
  return error instanceof Error && typeof error.stack === 'string' ? error.stack : null;
53
54
  }
55
+ /** Extra characters kept before redaction so a secret at the size limit is seen whole. */
56
+ const SLACK = 1024;
54
57
  /** Keeps the top of the text — the start, where the throw site and its callers are — and drops the rest. */
55
58
  function truncateEnd(text, max) {
56
59
  return text.length > max ? text.slice(0, max) : text;
@@ -101,7 +104,7 @@ function normaliseRuntime(runtime) {
101
104
  * share one transaction so the lock from the first is still held for the
102
105
  * second.
103
106
  */
104
- async function upsertGroup(db, site, s) {
107
+ async function upsertGroup(db, site, s, log) {
105
108
  return db.transaction(async (tx) => {
106
109
  const prior = await tx
107
110
  .select({ state: reportingErrors.state })
@@ -120,6 +123,7 @@ async function upsertGroup(db, site, s) {
120
123
  stack: s.stack,
121
124
  runtime: s.runtime,
122
125
  release: s.release,
126
+ color: s.color,
123
127
  environment: s.environment,
124
128
  firstSeenAt: s.at,
125
129
  lastSeenAt: s.at,
@@ -131,12 +135,13 @@ async function upsertGroup(db, site, s) {
131
135
  target: reportingErrors.fingerprint,
132
136
  set: {
133
137
  occurrences: sql `${reportingErrors.occurrences} + 1`,
134
- lastSeenAt: s.at,
138
+ lastSeenAt: sql `greatest(${reportingErrors.lastSeenAt}, ${s.at.toISOString()}::timestamptz)`,
135
139
  message: s.message,
136
140
  stack: s.stack,
137
141
  tenantId: s.tenantId,
138
142
  requestId: s.requestId,
139
143
  release: s.release,
144
+ color: s.color,
140
145
  environment: s.environment,
141
146
  state: reopened,
142
147
  resolvedAt: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedAt} end`,
@@ -147,6 +152,33 @@ async function upsertGroup(db, site, s) {
147
152
  // false when it took the on-conflict-update path -- the standard,
148
153
  // race-free way to tell the two apart from an upsert's own result.
149
154
  .returning({ created: sql `(xmax = 0)` });
155
+ if (s.tenantId) {
156
+ // In a savepoint and fail-soft: a host that has not applied 0008 yet
157
+ // must still get the group row. The tenant row is the lesser loss.
158
+ try {
159
+ await tx.transaction(async (sp) => {
160
+ await sp
161
+ .insert(reportingErrorTenants)
162
+ .values({
163
+ fingerprint: s.fingerprint,
164
+ tenantId: s.tenantId,
165
+ occurrences: 1,
166
+ firstSeenAt: s.at,
167
+ lastSeenAt: s.at,
168
+ })
169
+ .onConflictDoUpdate({
170
+ target: [reportingErrorTenants.fingerprint, reportingErrorTenants.tenantId],
171
+ set: {
172
+ occurrences: sql `${reportingErrorTenants.occurrences} + 1`,
173
+ lastSeenAt: sql `greatest(${reportingErrorTenants.lastSeenAt}, ${s.at.toISOString()}::timestamptz)`,
174
+ },
175
+ });
176
+ });
177
+ }
178
+ catch (err) {
179
+ log.warn({ err: describe(err), fingerprint: s.fingerprint }, 'reporting: tenant row not written; is migration 0008 applied?');
180
+ }
181
+ }
150
182
  if (written[0]?.created)
151
183
  return 'created';
152
184
  return priorState === 'resolved' || priorState === 'ignored' ? 'reopened' : 'unchanged';
@@ -171,9 +203,15 @@ export function createCapture(o) {
171
203
  try {
172
204
  const kind = resolveKind(error, context.kind);
173
205
  const secrets = heldSecrets(process.env, o.redactEnvVars);
174
- const message = truncateEnd(redactText(messageOf(error, kind), secrets), LIMITS.message);
206
+ // Bound the text before redacting it: the patterns must never see an
207
+ // uncapped, attacker-sized string. The cut keeps slack past the limit so
208
+ // a secret straddling the limit is redacted whole, then the final cut
209
+ // applies the real limit.
210
+ const message = truncateEnd(redactPii(redactText(truncateEnd(messageOf(error, kind), LIMITS.message + SLACK), secrets)), LIMITS.message);
175
211
  const rawStack = stackOf(error);
176
- const stack = rawStack === null ? null : truncateEnd(redactText(rawStack, secrets), LIMITS.stack);
212
+ const stack = rawStack === null
213
+ ? null
214
+ : truncateEnd(redactPii(redactText(truncateEnd(rawStack, LIMITS.stack + SLACK), secrets)), LIMITS.stack);
177
215
  const fp = fingerprint({ kind, message, stack });
178
216
  const runtime = normaliseRuntime(context.runtime);
179
217
  // A tenant id that is not a uuid would be refused by the event row's
@@ -202,13 +240,14 @@ export function createCapture(o) {
202
240
  stack,
203
241
  runtime,
204
242
  release,
243
+ color: deployTags().color,
205
244
  environment,
206
245
  tenantId,
207
246
  requestId,
208
247
  at,
209
248
  };
210
249
  try {
211
- o.defer(() => upsertGroup(o.db, o.site, sample)
250
+ o.defer(() => upsertGroup(o.db, o.site, sample, o.log)
212
251
  .then((outcome) => {
213
252
  if (outcome === 'unchanged' || !o.alert)
214
253
  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,76 @@
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
+ color: sql `null`.as('color'),
39
+ requestId: sql `null`.as('request_id'),
40
+ resolvedBy: sql `null`.as('resolved_by'),
41
+ state: sql `null`.as('state'),
42
+ resolvedAt: sql `null`.as('resolved_at'),
43
+ tenantId: reportingErrorTenants.tenantId,
44
+ occurrences: reportingErrorTenants.occurrences,
45
+ firstSeenAt: reportingErrorTenants.firstSeenAt,
46
+ lastSeenAt: reportingErrorTenants.lastSeenAt,
47
+ };
48
+ }
17
49
  export async function errorsPage(db, opts = {}) {
50
+ const tenantId = tenantScope(opts);
18
51
  const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
52
+ // With a tenant, the page is that tenant's own view: its rows join the
53
+ // group and supply the counts and times, so ordering and paging follow the
54
+ // tenant's last occurrence, not anyone else's.
55
+ const seen = tenantId !== undefined ? reportingErrorTenants.lastSeenAt : reportingErrors.lastSeenAt;
19
56
  const conditions = [];
20
57
  if (opts.site)
21
58
  conditions.push(eq(reportingErrors.site, opts.site));
22
- if (opts.state)
59
+ // `state` is shared operator triage, so in tenant scope it is ignored: a
60
+ // filter on it would show one tenant whether another reopened the group.
61
+ if (opts.state && tenantId === undefined)
23
62
  conditions.push(eq(reportingErrors.state, opts.state));
24
- if (opts.runtime)
63
+ // `runtime` and the text are the latest sample from any tenant, so a tenant
64
+ // scope neither filters on the first nor searches the rest: either would be
65
+ // an oracle on another tenant's data. Search matches `kind` there.
66
+ if (opts.runtime && tenantId === undefined)
25
67
  conditions.push(eq(reportingErrors.runtime, opts.runtime));
26
68
  const search = opts.search?.trim().slice(0, 200);
27
69
  if (search) {
28
70
  const pattern = likePattern(search);
29
- const clause = or(ilike(reportingErrors.message, pattern), ilike(reportingErrors.stack, pattern));
71
+ const clause = tenantId !== undefined
72
+ ? ilike(reportingErrors.kind, pattern)
73
+ : or(ilike(reportingErrors.message, pattern), ilike(reportingErrors.stack, pattern));
30
74
  if (clause)
31
75
  conditions.push(clause);
32
76
  }
@@ -34,16 +78,26 @@ export async function errorsPage(db, opts = {}) {
34
78
  conditions.push(eq(reportingErrors.environment, opts.environment));
35
79
  if (opts.after) {
36
80
  const { lastSeenAt, fingerprint } = opts.after;
37
- const keyset = or(lt(reportingErrors.lastSeenAt, lastSeenAt), and(eq(reportingErrors.lastSeenAt, lastSeenAt), lt(reportingErrors.fingerprint, fingerprint)));
81
+ const keyset = or(lt(seen, lastSeenAt), and(eq(seen, lastSeenAt), lt(reportingErrors.fingerprint, fingerprint)));
38
82
  if (keyset)
39
83
  conditions.push(keyset);
40
84
  }
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);
85
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
86
+ const order = [desc(seen), desc(reportingErrors.fingerprint)];
87
+ const rows = tenantId !== undefined
88
+ ? await db
89
+ .select(tenantColumns())
90
+ .from(reportingErrors)
91
+ .innerJoin(reportingErrorTenants, and(eq(reportingErrorTenants.fingerprint, reportingErrors.fingerprint), eq(reportingErrorTenants.tenantId, tenantId)))
92
+ .where(where)
93
+ .orderBy(...order)
94
+ .limit(limit + 1)
95
+ : await db
96
+ .select()
97
+ .from(reportingErrors)
98
+ .where(where)
99
+ .orderBy(...order)
100
+ .limit(limit + 1);
47
101
  const items = rows.slice(0, limit);
48
102
  const last = items.at(-1);
49
103
  return {
@@ -53,8 +107,17 @@ export async function errorsPage(db, opts = {}) {
53
107
  : null,
54
108
  };
55
109
  }
56
- /** One row by fingerprint, or null when it is unknown. */
57
- export async function errorDetail(db, fingerprint) {
110
+ export async function errorDetail(db, fingerprint, opts = {}) {
111
+ const tenantId = tenantScope(opts);
112
+ if (tenantId !== undefined) {
113
+ const scoped = await db
114
+ .select(tenantColumns())
115
+ .from(reportingErrors)
116
+ .innerJoin(reportingErrorTenants, and(eq(reportingErrorTenants.fingerprint, reportingErrors.fingerprint), eq(reportingErrorTenants.tenantId, tenantId)))
117
+ .where(eq(reportingErrors.fingerprint, fingerprint))
118
+ .limit(1);
119
+ return scoped[0] ?? null;
120
+ }
58
121
  const rows = await db
59
122
  .select()
60
123
  .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
+ }