@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.
@@ -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 {
@@ -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) }));
@@ -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
@@ -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`, one round trip so two
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
- const reopened = sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then 'open' else ${reportingErrors.state} end`;
87
- await db
88
- .insert(reportingErrors)
89
- .values({
90
- fingerprint: s.fingerprint,
91
- site,
92
- kind: s.kind,
93
- message: s.message,
94
- stack: s.stack,
95
- runtime: s.runtime,
96
- release: s.release,
97
- firstSeenAt: s.at,
98
- lastSeenAt: s.at,
99
- occurrences: 1,
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
- release: s.release,
113
- state: reopened,
114
- resolvedAt: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedAt} end`,
115
- resolvedBy: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedBy} end`,
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).catch((err) => {
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
  }
@@ -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. No permission is
7
- * applied here: the host gates, and this returns rows as stored.
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. */
@@ -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. No permission is
6
- * applied here: the host gates, and this returns rows as stored.
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)));
@@ -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>>): string[];
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
@@ -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 = [env.KEYSTORE_KEK ?? '', ...(env.KEYSTORE_KEK_PREVIOUS ?? '').split(',')]
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();
@@ -17,4 +17,6 @@ export interface FakeReporting extends Reporting {
17
17
  export declare function createFakeReporting(opts?: {
18
18
  site?: string;
19
19
  log?: Logger;
20
+ redactEnvVars?: readonly string[];
21
+ environment?: string | null;
20
22
  }): FakeReporting;
@@ -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(finding) {
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;