@wtfalch/reporting 0.5.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -15
- package/dist/analytics/write.js +3 -0
- package/dist/deploy.d.ts +11 -0
- package/dist/deploy.js +13 -0
- package/dist/errors/capture.js +46 -7
- package/dist/errors/reader.d.ts +22 -3
- package/dist/errors/reader.js +84 -21
- package/dist/errors/redact.d.ts +1 -0
- package/dist/errors/redact.js +63 -0
- package/dist/errors/tasks.d.ts +15 -0
- package/dist/errors/tasks.js +47 -0
- package/dist/housekeeping/index.d.ts +2 -0
- package/dist/housekeeping/index.js +2 -0
- package/dist/index.js +13 -1
- package/dist/migrations/0001_reporting.sql +6 -20
- package/dist/migrations/0002_analytics.sql +6 -20
- package/dist/migrations/0003_errors.sql +4 -16
- package/dist/migrations/0006_tenant_settings.sql +3 -21
- package/dist/migrations/0007_errors_open_cap.sql +42 -0
- package/dist/migrations/0008_error_tenants.sql +35 -0
- package/dist/migrations/0009_release_color.sql +33 -0
- package/dist/migrations/0010_requests.sql +27 -0
- package/dist/migrations-dir.d.ts +11 -0
- package/dist/migrations-dir.js +12 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/release-health.d.ts +22 -0
- package/dist/next/release-health.js +122 -0
- package/dist/react/errors.d.ts +2 -2
- package/dist/react/errors.js +1 -1
- package/dist/requests.d.ts +22 -0
- package/dist/requests.js +72 -0
- package/dist/tables.d.ts +609 -16
- package/dist/tables.js +32 -0
- package/dist/types.d.ts +12 -4
- package/dist/writer.js +2 -0
- package/package.json +8 -8
- package/dist/fake/index.d.ts +0 -22
- package/dist/fake/index.js +0 -157
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
package/dist/analytics/write.js
CHANGED
|
@@ -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
|
}
|
package/dist/deploy.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors/capture.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/errors/reader.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
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'
|
package/dist/errors/reader.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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)
|
package/dist/errors/redact.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/errors/redact.js
CHANGED
|
@@ -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
|
*
|
package/dist/errors/tasks.d.ts
CHANGED
|
@@ -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>;
|
package/dist/errors/tasks.js
CHANGED
|
@@ -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
|
+
}
|