@wtfalch/reporting 0.3.1 → 0.4.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/dist/analytics/reader.d.ts +7 -0
- package/dist/analytics/reader.js +1 -1
- package/dist/errors/capture.d.ts +23 -1
- package/dist/errors/capture.js +90 -32
- package/dist/errors/reader.d.ts +7 -2
- package/dist/errors/reader.js +21 -3
- package/dist/errors/redact.d.ts +7 -1
- package/dist/errors/redact.js +12 -2
- package/dist/fake/index.d.ts +2 -0
- package/dist/fake/index.js +29 -0
- package/dist/index.js +28 -13
- package/dist/migrations/0004_environment.sql +36 -0
- package/dist/migrations/0005_analytics_session_index.sql +14 -0
- package/dist/migrations/0006_tenant_settings.sql +142 -0
- package/dist/next/edge-errors.d.ts +50 -0
- package/dist/next/edge-errors.js +131 -0
- package/dist/next/index.d.ts +10 -0
- package/dist/next/index.js +13 -0
- package/dist/reader.js +2 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +12 -0
- package/dist/settings.d.ts +21 -0
- package/dist/settings.js +53 -2
- package/dist/tables.d.ts +254 -0
- package/dist/tables.js +16 -0
- package/dist/types.d.ts +29 -0
- package/dist/writer.d.ts +1 -0
- package/dist/writer.js +1 -0
- package/package.json +1 -1
|
@@ -43,6 +43,13 @@ export interface RecentOptions {
|
|
|
43
43
|
readonly site: string;
|
|
44
44
|
readonly tenantId?: string;
|
|
45
45
|
readonly name?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Everything one session did (gap issue #9): the lightest
|
|
48
|
+
* privacy-consistent equivalent of session replay, since every row
|
|
49
|
+
* already carries the visitor's per-tab session id and nothing else this
|
|
50
|
+
* reader would not already show.
|
|
51
|
+
*/
|
|
52
|
+
readonly sessionId?: string;
|
|
46
53
|
readonly limit?: number;
|
|
47
54
|
}
|
|
48
55
|
export interface RecentEvent {
|
package/dist/analytics/reader.js
CHANGED
|
@@ -78,7 +78,7 @@ export async function analyticsRecent(db, opts) {
|
|
|
78
78
|
signedIn: sql `${t.userId} is not null`,
|
|
79
79
|
})
|
|
80
80
|
.from(t)
|
|
81
|
-
.where(and(eq(t.site, opts.site), opts.tenantId ? eq(t.tenantId, opts.tenantId) : undefined, opts.name ? eq(t.name, opts.name) : undefined))
|
|
81
|
+
.where(and(eq(t.site, opts.site), opts.tenantId ? eq(t.tenantId, opts.tenantId) : undefined, opts.name ? eq(t.name, opts.name) : undefined, opts.sessionId ? eq(t.sessionId, opts.sessionId) : undefined))
|
|
82
82
|
.orderBy(desc(t.occurredAt), desc(t.id))
|
|
83
83
|
.limit(limit);
|
|
84
84
|
return rows.map((r) => ({ ...r, signedIn: Boolean(r.signedIn) }));
|
package/dist/errors/capture.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CaptureContext, Db, EventInput, Logger } from '../types.js';
|
|
1
|
+
import type { AlertFinding, CaptureContext, Db, EventInput, Logger } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* The one path an uncaught exception takes into the estate
|
|
4
4
|
* (docs/plans/errors.md, "Surface" and "Why a second table, not just an
|
|
@@ -7,6 +7,14 @@ import type { CaptureContext, Db, EventInput, Logger } from '../types.js';
|
|
|
7
7
|
* and one upsert into the fingerprint's group — first seen, last seen, how
|
|
8
8
|
* many, and whether an operator has already dealt with it.
|
|
9
9
|
*
|
|
10
|
+
* A group that is brand new, or that just reopened from resolved or
|
|
11
|
+
* ignored, also fires `o.alert` once -- never a repeat occurrence of an
|
|
12
|
+
* error that was already open, which would make this a flood rather than a
|
|
13
|
+
* page. `o.alert` is the same host-supplied `onAlert` hook `Reporting.alert`
|
|
14
|
+
* already writes an `alert.<check>` row and calls (index.ts's `fireAlert`);
|
|
15
|
+
* this package holds no vendor of its own to page through.
|
|
16
|
+
*
|
|
17
|
+
|
|
10
18
|
* Two contracts this must never break, both sharpened from the writer's own
|
|
11
19
|
* (writer.ts's header comment) for an error reporter specifically:
|
|
12
20
|
*
|
|
@@ -27,6 +35,8 @@ export interface CaptureOptions {
|
|
|
27
35
|
readonly db: Db;
|
|
28
36
|
readonly log: Logger;
|
|
29
37
|
readonly site: string;
|
|
38
|
+
/** Stamped on every group the same way `release` is; see `ReportingOptions.environment`. */
|
|
39
|
+
readonly environment?: string | null;
|
|
30
40
|
readonly now: () => Date;
|
|
31
41
|
/** Runs the group upsert after the current unit of work, same as the writer's flush (writer.ts). */
|
|
32
42
|
readonly defer: (fn: () => Promise<void>) => void;
|
|
@@ -34,7 +44,19 @@ export interface CaptureOptions {
|
|
|
34
44
|
readonly event: (input: EventInput) => void;
|
|
35
45
|
/** Stamped on a fresh group when a capture's own context supplies none. */
|
|
36
46
|
readonly release?: string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Fires once when `captureError` creates a fresh group or reopens a
|
|
49
|
+
* resolved/ignored one -- never on a repeat occurrence of an
|
|
50
|
+
* already-open error, which would make this a flood rather than a page.
|
|
51
|
+
* The host's own `onAlert` (docs/plans/errors.md: "a pager is a later
|
|
52
|
+
* decision now that nothing pages" -- this is that decision). Never
|
|
53
|
+
* called for a group upsert that fails or is dropped.
|
|
54
|
+
*/
|
|
55
|
+
readonly alert?: (finding: AlertFinding) => void;
|
|
56
|
+
/** Extra env var names `heldSecrets` also redacts, beyond the estate's own KEYSTORE_*; see `ReportingOptions.redactEnvVars`. */
|
|
57
|
+
readonly redactEnvVars?: readonly string[];
|
|
37
58
|
}
|
|
59
|
+
export type UpsertOutcome = 'created' | 'reopened' | 'unchanged';
|
|
38
60
|
/**
|
|
39
61
|
* `captureError(error, context?)`: the class from `errorKind` (or the
|
|
40
62
|
* context's own, when the caller already knows it), the message and stack
|
package/dist/errors/capture.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from 'drizzle-orm';
|
|
1
|
+
import { eq, sql } from 'drizzle-orm';
|
|
2
2
|
import { ERROR_RUNTIMES, KIND_PATTERN, LIMITS } from '../schema.js';
|
|
3
3
|
import { reportingErrors } from '../tables.js';
|
|
4
4
|
import { describe } from '../writer.js';
|
|
@@ -71,8 +71,7 @@ function normaliseRuntime(runtime) {
|
|
|
71
71
|
return runtime && ERROR_RUNTIMES.includes(runtime) ? runtime : 'server';
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
* `insert ... on conflict (fingerprint) do update
|
|
75
|
-
* occurrences arriving at once still land as one row: bump `occurrences`,
|
|
74
|
+
* `insert ... on conflict (fingerprint) do update`: bump `occurrences`,
|
|
76
75
|
* move `last_seen_at`, overwrite the sample fields with the newest
|
|
77
76
|
* occurrence. `first_seen_at` is left out of `set` entirely, so a conflict
|
|
78
77
|
* never moves it.
|
|
@@ -81,39 +80,76 @@ function normaliseRuntime(runtime) {
|
|
|
81
80
|
* enforces (`reporting_errors_resolved_pair_check`, 0003_errors.sql) means
|
|
82
81
|
* `state` can only move back to 'open' by clearing `resolved_at` and
|
|
83
82
|
* `resolved_by` in the same statement, never one alone.
|
|
83
|
+
*
|
|
84
|
+
* Telling a brand-new group from a reopened one from a repeat occurrence
|
|
85
|
+
* needs the state from *before* this upsert touches it, and that read must
|
|
86
|
+
* not race a concurrent capture of the same fingerprint -- two occurrences
|
|
87
|
+
* arriving at once must still alert once, the same guarantee the row
|
|
88
|
+
* itself already had. A plain `select` run alongside the upsert (even in
|
|
89
|
+
* the same statement, via a CTE) does not give that: under READ COMMITTED
|
|
90
|
+
* a statement's snapshot is fixed at its start, so two concurrent
|
|
91
|
+
* transactions' own plain reads can each see "no row yet" even after one
|
|
92
|
+
* of them has since committed the insert -- there is no re-check the way
|
|
93
|
+
* an UPDATE's own target row gets. `select ... for update` does get that
|
|
94
|
+
* re-check (the same special case that makes `occurrences + 1` correct
|
|
95
|
+
* under conflict): it locks an existing row, so a second transaction's own
|
|
96
|
+
* `for update` blocks until the first commits, then reads the fresh
|
|
97
|
+
* value -- and locks nothing when there is no row yet, which is fine,
|
|
98
|
+
* because "created" is decided separately, from the insert's own `xmax`
|
|
99
|
+
* (0 exactly when this statement is what created the row, never a race:
|
|
100
|
+
* it reflects the row this transaction's own write produced). Both reads
|
|
101
|
+
* share one transaction so the lock from the first is still held for the
|
|
102
|
+
* second.
|
|
84
103
|
*/
|
|
85
104
|
async function upsertGroup(db, site, s) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
tenantId: s.tenantId,
|
|
101
|
-
requestId: s.requestId,
|
|
102
|
-
})
|
|
103
|
-
.onConflictDoUpdate({
|
|
104
|
-
target: reportingErrors.fingerprint,
|
|
105
|
-
set: {
|
|
106
|
-
occurrences: sql `${reportingErrors.occurrences} + 1`,
|
|
107
|
-
lastSeenAt: s.at,
|
|
105
|
+
return db.transaction(async (tx) => {
|
|
106
|
+
const prior = await tx
|
|
107
|
+
.select({ state: reportingErrors.state })
|
|
108
|
+
.from(reportingErrors)
|
|
109
|
+
.where(eq(reportingErrors.fingerprint, s.fingerprint))
|
|
110
|
+
.for('update');
|
|
111
|
+
const priorState = prior[0]?.state ?? null;
|
|
112
|
+
const reopened = sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then 'open' else ${reportingErrors.state} end`;
|
|
113
|
+
const written = await tx
|
|
114
|
+
.insert(reportingErrors)
|
|
115
|
+
.values({
|
|
116
|
+
fingerprint: s.fingerprint,
|
|
117
|
+
site,
|
|
118
|
+
kind: s.kind,
|
|
108
119
|
message: s.message,
|
|
109
120
|
stack: s.stack,
|
|
121
|
+
runtime: s.runtime,
|
|
122
|
+
release: s.release,
|
|
123
|
+
environment: s.environment,
|
|
124
|
+
firstSeenAt: s.at,
|
|
125
|
+
lastSeenAt: s.at,
|
|
126
|
+
occurrences: 1,
|
|
110
127
|
tenantId: s.tenantId,
|
|
111
128
|
requestId: s.requestId,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
})
|
|
130
|
+
.onConflictDoUpdate({
|
|
131
|
+
target: reportingErrors.fingerprint,
|
|
132
|
+
set: {
|
|
133
|
+
occurrences: sql `${reportingErrors.occurrences} + 1`,
|
|
134
|
+
lastSeenAt: s.at,
|
|
135
|
+
message: s.message,
|
|
136
|
+
stack: s.stack,
|
|
137
|
+
tenantId: s.tenantId,
|
|
138
|
+
requestId: s.requestId,
|
|
139
|
+
release: s.release,
|
|
140
|
+
environment: s.environment,
|
|
141
|
+
state: reopened,
|
|
142
|
+
resolvedAt: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedAt} end`,
|
|
143
|
+
resolvedBy: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedBy} end`,
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
// xmax = 0 is true exactly for a row this statement inserted fresh,
|
|
147
|
+
// false when it took the on-conflict-update path -- the standard,
|
|
148
|
+
// race-free way to tell the two apart from an upsert's own result.
|
|
149
|
+
.returning({ created: sql `(xmax = 0)` });
|
|
150
|
+
if (written[0]?.created)
|
|
151
|
+
return 'created';
|
|
152
|
+
return priorState === 'resolved' || priorState === 'ignored' ? 'reopened' : 'unchanged';
|
|
117
153
|
});
|
|
118
154
|
}
|
|
119
155
|
/**
|
|
@@ -134,7 +170,7 @@ export function createCapture(o) {
|
|
|
134
170
|
return function captureError(error, context = {}) {
|
|
135
171
|
try {
|
|
136
172
|
const kind = resolveKind(error, context.kind);
|
|
137
|
-
const secrets = heldSecrets(process.env);
|
|
173
|
+
const secrets = heldSecrets(process.env, o.redactEnvVars);
|
|
138
174
|
const message = truncateEnd(redactText(messageOf(error, kind), secrets), LIMITS.message);
|
|
139
175
|
const rawStack = stackOf(error);
|
|
140
176
|
const stack = rawStack === null ? null : truncateEnd(redactText(rawStack, secrets), LIMITS.stack);
|
|
@@ -149,6 +185,7 @@ export function createCapture(o) {
|
|
|
149
185
|
const tenantId = validTenant(context.tenantId);
|
|
150
186
|
const requestId = context.requestId ?? null;
|
|
151
187
|
const release = context.release ?? o.release ?? null;
|
|
188
|
+
const environment = o.environment ?? null;
|
|
152
189
|
const at = o.now();
|
|
153
190
|
o.event({
|
|
154
191
|
kind: eventKindFor(kind),
|
|
@@ -165,12 +202,33 @@ export function createCapture(o) {
|
|
|
165
202
|
stack,
|
|
166
203
|
runtime,
|
|
167
204
|
release,
|
|
205
|
+
environment,
|
|
168
206
|
tenantId,
|
|
169
207
|
requestId,
|
|
170
208
|
at,
|
|
171
209
|
};
|
|
172
210
|
try {
|
|
173
|
-
o.defer(() => upsertGroup(o.db, o.site, sample)
|
|
211
|
+
o.defer(() => upsertGroup(o.db, o.site, sample)
|
|
212
|
+
.then((outcome) => {
|
|
213
|
+
if (outcome === 'unchanged' || !o.alert)
|
|
214
|
+
return;
|
|
215
|
+
try {
|
|
216
|
+
o.alert({
|
|
217
|
+
check: outcome === 'created' ? 'new_error' : 'error_reopened',
|
|
218
|
+
message: `${outcome === 'created' ? 'new error' : 'error reopened'}: ${sample.kind}: ${sample.message}`.slice(0, 512),
|
|
219
|
+
detail: {
|
|
220
|
+
fingerprint: sample.fingerprint,
|
|
221
|
+
kind: sample.kind,
|
|
222
|
+
runtime: sample.runtime,
|
|
223
|
+
site: o.site,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
o.log.error({ err: describe(err) }, 'reporting: error alert threw');
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
.catch((err) => {
|
|
174
232
|
o.log.error({ err: describe(err), fingerprint: fp }, 'reporting: error group upsert failed');
|
|
175
233
|
}));
|
|
176
234
|
}
|
package/dist/errors/reader.d.ts
CHANGED
|
@@ -3,8 +3,13 @@ import type { ReportingErrorRow } 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
|
|
6
|
-
* index. Every filter is an equality on an indexed column
|
|
7
|
-
*
|
|
6
|
+
* index. Every filter is an equality on an indexed column, except `search`:
|
|
7
|
+
* a case-insensitive substring match on `message` or `stack`, for triaging
|
|
8
|
+
* an incident by grepping the group list rather than raw rows by hand (gap
|
|
9
|
+
* issue #8). Unindexed by design -- a trigram index would need the
|
|
10
|
+
* pg_trgm extension, an operational cost this triage tool does not carry
|
|
11
|
+
* for its scale. No permission is applied here: the host gates, and this
|
|
12
|
+
* returns rows as stored.
|
|
8
13
|
*/
|
|
9
14
|
export declare function errorsPage(db: Db, opts?: ErrorsPageOptions): Promise<ErrorsPage>;
|
|
10
15
|
/** One row by fingerprint, or null when it is unknown. */
|
package/dist/errors/reader.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import { and, desc, eq, lt, or, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, desc, eq, ilike, lt, or, sql } from 'drizzle-orm';
|
|
2
2
|
import { reportingErrors } from '../tables.js';
|
|
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
|
+
function likePattern(term) {
|
|
5
|
+
return `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
6
|
+
}
|
|
3
7
|
/**
|
|
4
8
|
* Newest first, keyset-paged on `(last_seen_at, fingerprint)`, the table's
|
|
5
|
-
* index. Every filter is an equality on an indexed column
|
|
6
|
-
*
|
|
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.
|
|
7
16
|
*/
|
|
8
17
|
export async function errorsPage(db, opts = {}) {
|
|
9
18
|
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
@@ -14,6 +23,15 @@ export async function errorsPage(db, opts = {}) {
|
|
|
14
23
|
conditions.push(eq(reportingErrors.state, opts.state));
|
|
15
24
|
if (opts.runtime)
|
|
16
25
|
conditions.push(eq(reportingErrors.runtime, opts.runtime));
|
|
26
|
+
const search = opts.search?.trim().slice(0, 200);
|
|
27
|
+
if (search) {
|
|
28
|
+
const pattern = likePattern(search);
|
|
29
|
+
const clause = or(ilike(reportingErrors.message, pattern), ilike(reportingErrors.stack, pattern));
|
|
30
|
+
if (clause)
|
|
31
|
+
conditions.push(clause);
|
|
32
|
+
}
|
|
33
|
+
if (opts.environment)
|
|
34
|
+
conditions.push(eq(reportingErrors.environment, opts.environment));
|
|
17
35
|
if (opts.after) {
|
|
18
36
|
const { lastSeenAt, fingerprint } = opts.after;
|
|
19
37
|
const keyset = or(lt(reportingErrors.lastSeenAt, lastSeenAt), and(eq(reportingErrors.lastSeenAt, lastSeenAt), lt(reportingErrors.fingerprint, fingerprint)));
|
package/dist/errors/redact.d.ts
CHANGED
|
@@ -28,8 +28,14 @@ export declare const REDACTED = "[redacted: wrapping key]";
|
|
|
28
28
|
* The exact strings that must never appear in anything sent anywhere: both
|
|
29
29
|
* environment variables in full, and each key's base64 half on its own, since
|
|
30
30
|
* a parser that split on the colon would report only that part.
|
|
31
|
+
*
|
|
32
|
+
* `extraEnvVars` is how a host extends the list beyond the estate's own
|
|
33
|
+
* KEYSTORE_* convention (gap issue #7): each named variable is read the same
|
|
34
|
+
* way `KEYSTORE_KEK_PREVIOUS` is -- comma-separated values allowed, each
|
|
35
|
+
* trimmed -- so a host's own rotation scheme is covered the same way the
|
|
36
|
+
* estate's is, with no new format to learn.
|
|
31
37
|
*/
|
|
32
|
-
export declare function heldSecrets(env: Readonly<Record<string, string | undefined
|
|
38
|
+
export declare function heldSecrets(env: Readonly<Record<string, string | undefined>>, extraEnvVars?: readonly string[]): string[];
|
|
33
39
|
/**
|
|
34
40
|
* The string case alone: a captured `message` and `stack` are strings before
|
|
35
41
|
* they are anything else, so the capture path scrubs them directly rather
|
package/dist/errors/redact.js
CHANGED
|
@@ -28,9 +28,19 @@ export const REDACTED = '[redacted: wrapping key]';
|
|
|
28
28
|
* The exact strings that must never appear in anything sent anywhere: both
|
|
29
29
|
* environment variables in full, and each key's base64 half on its own, since
|
|
30
30
|
* a parser that split on the colon would report only that part.
|
|
31
|
+
*
|
|
32
|
+
* `extraEnvVars` is how a host extends the list beyond the estate's own
|
|
33
|
+
* KEYSTORE_* convention (gap issue #7): each named variable is read the same
|
|
34
|
+
* way `KEYSTORE_KEK_PREVIOUS` is -- comma-separated values allowed, each
|
|
35
|
+
* trimmed -- so a host's own rotation scheme is covered the same way the
|
|
36
|
+
* estate's is, with no new format to learn.
|
|
31
37
|
*/
|
|
32
|
-
export function heldSecrets(env) {
|
|
33
|
-
const specs = [
|
|
38
|
+
export function heldSecrets(env, extraEnvVars = []) {
|
|
39
|
+
const specs = [
|
|
40
|
+
env.KEYSTORE_KEK ?? '',
|
|
41
|
+
...(env.KEYSTORE_KEK_PREVIOUS ?? '').split(','),
|
|
42
|
+
...extraEnvVars.flatMap((name) => (env[name] ?? '').split(',')),
|
|
43
|
+
]
|
|
34
44
|
.map((spec) => spec.trim())
|
|
35
45
|
.filter((spec) => spec.length > 0);
|
|
36
46
|
const secrets = new Set();
|
package/dist/fake/index.d.ts
CHANGED
package/dist/fake/index.js
CHANGED
|
@@ -6,11 +6,13 @@ import { tables } from '../tables.js';
|
|
|
6
6
|
const silent = { info() { }, warn() { }, error() { } };
|
|
7
7
|
export function createFakeReporting(opts = {}) {
|
|
8
8
|
const site = opts.site ?? 'test';
|
|
9
|
+
const environment = opts.environment ?? null;
|
|
9
10
|
const log = opts.log ?? silent;
|
|
10
11
|
const rows = [];
|
|
11
12
|
const alerts = [];
|
|
12
13
|
const tracked = [];
|
|
13
14
|
let settings = { ...DEFAULT_SETTINGS };
|
|
15
|
+
const tenantRetention = new Map();
|
|
14
16
|
let id = 0;
|
|
15
17
|
// No group table to fake, so the upsert never runs (`defer` is a no-op);
|
|
16
18
|
// the timeline row through `fake.event` is what a host's test asserts on.
|
|
@@ -21,6 +23,7 @@ export function createFakeReporting(opts = {}) {
|
|
|
21
23
|
now: () => new Date(),
|
|
22
24
|
defer: () => { },
|
|
23
25
|
event: (input) => fake.event(input),
|
|
26
|
+
redactEnvVars: opts.redactEnvVars,
|
|
24
27
|
});
|
|
25
28
|
const fake = {
|
|
26
29
|
site,
|
|
@@ -39,6 +42,7 @@ export function createFakeReporting(opts = {}) {
|
|
|
39
42
|
kind: v.kind,
|
|
40
43
|
kindNs: v.kind.split('.')[0] ?? '',
|
|
41
44
|
site,
|
|
45
|
+
environment,
|
|
42
46
|
tenantId: v.tenantId ?? null,
|
|
43
47
|
actorClass: v.actor?.class ?? null,
|
|
44
48
|
actorId: v.actor?.id ?? null,
|
|
@@ -70,6 +74,8 @@ export function createFakeReporting(opts = {}) {
|
|
|
70
74
|
list = list.filter((r) => r.site === o.site);
|
|
71
75
|
if (o.requestId)
|
|
72
76
|
list = list.filter((r) => r.requestId === o.requestId);
|
|
77
|
+
if (o.environment)
|
|
78
|
+
list = list.filter((r) => r.environment === o.environment);
|
|
73
79
|
if (o.after) {
|
|
74
80
|
const { occurredAt, id: afterId } = o.after;
|
|
75
81
|
list = list.filter((r) => r.occurredAt.getTime() < occurredAt.getTime() ||
|
|
@@ -102,6 +108,28 @@ export function createFakeReporting(opts = {}) {
|
|
|
102
108
|
return { before, after: settings, by, changed };
|
|
103
109
|
},
|
|
104
110
|
},
|
|
111
|
+
tenantSettings: {
|
|
112
|
+
async get(tenantId) {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const key of ['events.retention_days', 'analytics.retention_days']) {
|
|
115
|
+
const value = tenantRetention.get(`${tenantId}:${key}`);
|
|
116
|
+
if (value !== undefined)
|
|
117
|
+
out[key] = value;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
},
|
|
121
|
+
async set(tenantId, key, days) {
|
|
122
|
+
const mapKey = `${tenantId}:${key}`;
|
|
123
|
+
if (days === null) {
|
|
124
|
+
tenantRetention.delete(mapKey);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isInteger(days) || days < RETENTION_BOUNDS.min || days > RETENTION_BOUNDS.max) {
|
|
128
|
+
throw new Error(`reporting.tenantSettings: ${key} out of bounds`);
|
|
129
|
+
}
|
|
130
|
+
tenantRetention.set(mapKey, days);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
105
133
|
analytics: {
|
|
106
134
|
async track(input) {
|
|
107
135
|
tracked.push({ ...input, site });
|
|
@@ -121,6 +149,7 @@ export function createFakeReporting(opts = {}) {
|
|
|
121
149
|
rows.length = 0;
|
|
122
150
|
alerts.length = 0;
|
|
123
151
|
settings = { ...DEFAULT_SETTINGS };
|
|
152
|
+
tenantRetention.clear();
|
|
124
153
|
id = 0;
|
|
125
154
|
},
|
|
126
155
|
};
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,8 @@ import { ANALYTICS_NAME_PATTERN, normalisePath, propsSchema } from './analytics/
|
|
|
4
4
|
import { insertAnalytics } from './analytics/write.js';
|
|
5
5
|
import { createCapture } from './errors/capture.js';
|
|
6
6
|
import { eventsPage } from './reader.js';
|
|
7
|
-
import { siteSchema } from './schema.js';
|
|
8
|
-
import { getSettings, setSettings } from './settings.js';
|
|
7
|
+
import { environmentSchema, siteSchema } from './schema.js';
|
|
8
|
+
import { getSettings, getTenantRetention, setSettings, setTenantRetention } from './settings.js';
|
|
9
9
|
import { tables } from './tables.js';
|
|
10
10
|
import { Writer, describe } from './writer.js';
|
|
11
11
|
export * from './schema.js';
|
|
@@ -28,6 +28,7 @@ function modeFromEnv() {
|
|
|
28
28
|
*/
|
|
29
29
|
export function createReporting(options) {
|
|
30
30
|
const site = siteSchema.parse(options.site);
|
|
31
|
+
const environment = options.environment != null ? environmentSchema.parse(options.environment) : null;
|
|
31
32
|
const now = options.now ?? (() => new Date());
|
|
32
33
|
const mode = options.mode ?? modeFromEnv();
|
|
33
34
|
const defer = options.defer ??
|
|
@@ -38,6 +39,7 @@ export function createReporting(options) {
|
|
|
38
39
|
db: options.db,
|
|
39
40
|
log: options.log,
|
|
40
41
|
site,
|
|
42
|
+
environment,
|
|
41
43
|
mode,
|
|
42
44
|
now,
|
|
43
45
|
defer,
|
|
@@ -45,16 +47,35 @@ export function createReporting(options) {
|
|
|
45
47
|
batchSize: options.batchSize ?? 500,
|
|
46
48
|
flushEveryMs: options.flushEveryMs ?? 1000,
|
|
47
49
|
});
|
|
50
|
+
// One alerting path, shared by `reporting.alert()` and a new-or-reopened
|
|
51
|
+
// capture (errors/capture.ts's `alert` option): an `alert.<check>` row,
|
|
52
|
+
// already indexed and pruned the same as everything else on the
|
|
53
|
+
// timeline, then the host's own hook -- never a vendor's. A throw from
|
|
54
|
+
// that hook is the host's, not this package's, to fail on.
|
|
55
|
+
function fireAlert(finding) {
|
|
56
|
+
writer.event(alertRow(finding));
|
|
57
|
+
if (!options.onAlert)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
options.onAlert(finding);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
options.log.error({ err: describe(error) }, 'reporting: onAlert threw');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
48
66
|
// Same db, log, defer, now and site as the writer: one instance, one
|
|
49
67
|
// upsert path, no second connection or timer to keep in sync.
|
|
50
68
|
const capture = createCapture({
|
|
51
69
|
db: options.db,
|
|
52
70
|
log: options.log,
|
|
53
71
|
site,
|
|
72
|
+
environment,
|
|
54
73
|
now,
|
|
55
74
|
defer,
|
|
56
75
|
event: (input) => writer.event(input),
|
|
57
76
|
release: process.env.REPORTING_RELEASE ?? null,
|
|
77
|
+
alert: fireAlert,
|
|
78
|
+
redactEnvVars: options.redactEnvVars,
|
|
58
79
|
});
|
|
59
80
|
const reporting = {
|
|
60
81
|
site,
|
|
@@ -62,17 +83,7 @@ export function createReporting(options) {
|
|
|
62
83
|
tables,
|
|
63
84
|
event: (input) => writer.event(input),
|
|
64
85
|
captureError: (error, context) => capture(error, context),
|
|
65
|
-
alert
|
|
66
|
-
writer.event(alertRow(finding));
|
|
67
|
-
if (!options.onAlert)
|
|
68
|
-
return;
|
|
69
|
-
try {
|
|
70
|
-
options.onAlert(finding);
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
options.log.error({ err: describe(error) }, 'reporting: onAlert threw');
|
|
74
|
-
}
|
|
75
|
-
},
|
|
86
|
+
alert: fireAlert,
|
|
76
87
|
flush: (opts) => writer.flush(opts),
|
|
77
88
|
events: { page: (opts) => eventsPage(options.db, opts) },
|
|
78
89
|
settings: {
|
|
@@ -95,6 +106,10 @@ export function createReporting(options) {
|
|
|
95
106
|
return change;
|
|
96
107
|
},
|
|
97
108
|
},
|
|
109
|
+
tenantSettings: {
|
|
110
|
+
get: (tenantId) => getTenantRetention(options.db, tenantId),
|
|
111
|
+
set: (tenantId, key, days, by) => setTenantRetention(options.db, tenantId, key, days, by),
|
|
112
|
+
},
|
|
98
113
|
analytics: {
|
|
99
114
|
async track(input) {
|
|
100
115
|
try {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.4.0: an environment dimension on error and event
|
|
2
|
+
-- rows. Mirrors src/tables.ts.
|
|
3
|
+
--
|
|
4
|
+
-- Every factory app deploys prod + stage + one preview per PR, and site is
|
|
5
|
+
-- the app's identity, not its deploy target -- so without a second column,
|
|
6
|
+
-- preview noise and production issues land in the same rows with nothing to
|
|
7
|
+
-- tell them apart (gap issue #6). `environment` is a slug the host stamps
|
|
8
|
+
-- once, the way it already stamps `release`, never claimed per event.
|
|
9
|
+
--
|
|
10
|
+
-- Nullable and not fixed to an enum: unlike `level` or `runtime`, the estate
|
|
11
|
+
-- does not fix the set of environments a host may name, so the CHECK only
|
|
12
|
+
-- keeps it slug-shaped. Every statement is idempotent; the file is applied
|
|
13
|
+
-- by the host's own migrate script after `reporting-migrations` copies it in
|
|
14
|
+
-- as the next number.
|
|
15
|
+
--
|
|
16
|
+
-- `not valid` then a separate `validate constraint`, unlike
|
|
17
|
+
-- reporting_settings_key_check's plain add in 0002: that table is a handful
|
|
18
|
+
-- of rows, reporting_events and reporting_errors are the two tables this
|
|
19
|
+
-- package batches deletes against and tunes autovacuum for, so a CHECK added
|
|
20
|
+
-- the plain way would hold an ACCESS EXCLUSIVE lock for as long as it takes
|
|
21
|
+
-- to scan one of them. `not valid` takes that lock only for the catalog
|
|
22
|
+
-- change; `validate constraint` then scans under SHARE UPDATE EXCLUSIVE,
|
|
23
|
+
-- which blocks other DDL but not the reads and writes this package makes
|
|
24
|
+
-- every request.
|
|
25
|
+
|
|
26
|
+
alter table reporting_events add column if not exists environment text;
|
|
27
|
+
alter table reporting_events drop constraint if exists reporting_events_environment_check;
|
|
28
|
+
alter table reporting_events add constraint reporting_events_environment_check
|
|
29
|
+
check (environment is null or environment ~ '^[a-z][a-z0-9_-]{0,31}$') not valid;
|
|
30
|
+
alter table reporting_events validate constraint reporting_events_environment_check;
|
|
31
|
+
|
|
32
|
+
alter table reporting_errors add column if not exists environment text;
|
|
33
|
+
alter table reporting_errors drop constraint if exists reporting_errors_environment_check;
|
|
34
|
+
alter table reporting_errors add constraint reporting_errors_environment_check
|
|
35
|
+
check (environment is null or environment ~ '^[a-z][a-z0-9_-]{0,31}$') not valid;
|
|
36
|
+
alter table reporting_errors validate constraint reporting_errors_environment_check;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.4.0: a session-scoped index on reporting_analytics.
|
|
2
|
+
--
|
|
3
|
+
-- Debugging a tenant's user-reported bug means "everything this session
|
|
4
|
+
-- did," and every row already carries a per-tab session id -- the lightest
|
|
5
|
+
-- privacy-consistent equivalent of session replay (gap issue #9;
|
|
6
|
+
-- `analyticsRecent` gained a `sessionId` filter). Without an index shaped
|
|
7
|
+
-- for it, that filter is a sequential scan of the whole raw analytics
|
|
8
|
+
-- window on a busy site. Same shape as `reporting_analytics_tenant_time_idx`
|
|
9
|
+
-- (0002_analytics.sql): the filter column, then the reader's own order.
|
|
10
|
+
-- Idempotent, like every statement in this package's migrations.
|
|
11
|
+
|
|
12
|
+
create index if not exists reporting_analytics_session_time_idx
|
|
13
|
+
on reporting_analytics (session_id, occurred_at desc, id desc)
|
|
14
|
+
where session_id is not null;
|