@wtfalch/reporting 0.5.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +115 -15
  2. package/dist/analytics/write.js +3 -0
  3. package/dist/deploy.d.ts +11 -0
  4. package/dist/deploy.js +13 -0
  5. package/dist/errors/capture.js +46 -7
  6. package/dist/errors/reader.d.ts +22 -3
  7. package/dist/errors/reader.js +84 -21
  8. package/dist/errors/redact.d.ts +1 -0
  9. package/dist/errors/redact.js +63 -0
  10. package/dist/errors/tasks.d.ts +15 -0
  11. package/dist/errors/tasks.js +47 -0
  12. package/dist/housekeeping/index.d.ts +2 -0
  13. package/dist/housekeeping/index.js +2 -0
  14. package/dist/index.js +13 -1
  15. package/dist/migrations/0001_reporting.sql +6 -20
  16. package/dist/migrations/0002_analytics.sql +6 -20
  17. package/dist/migrations/0003_errors.sql +4 -16
  18. package/dist/migrations/0006_tenant_settings.sql +3 -21
  19. package/dist/migrations/0007_errors_open_cap.sql +42 -0
  20. package/dist/migrations/0008_error_tenants.sql +35 -0
  21. package/dist/migrations/0009_release_color.sql +33 -0
  22. package/dist/migrations/0010_requests.sql +27 -0
  23. package/dist/migrations-dir.d.ts +11 -0
  24. package/dist/migrations-dir.js +12 -0
  25. package/dist/next/index.d.ts +1 -0
  26. package/dist/next/index.js +1 -0
  27. package/dist/next/release-health.d.ts +22 -0
  28. package/dist/next/release-health.js +122 -0
  29. package/dist/react/errors.d.ts +2 -2
  30. package/dist/react/errors.js +1 -1
  31. package/dist/requests.d.ts +22 -0
  32. package/dist/requests.js +72 -0
  33. package/dist/tables.d.ts +609 -16
  34. package/dist/tables.js +32 -0
  35. package/dist/types.d.ts +12 -4
  36. package/dist/writer.js +2 -0
  37. package/package.json +8 -8
  38. package/dist/fake/index.d.ts +0 -22
  39. package/dist/fake/index.js +0 -157
package/dist/tables.js CHANGED
@@ -15,6 +15,8 @@ export const reportingEvents = pgTable('reporting_events', {
15
15
  kindNs: text('kind_ns').generatedAlwaysAs(sql `split_part(kind, '.', 1)`),
16
16
  site: text('site').notNull(),
17
17
  environment: text('environment'),
18
+ release: text('release'),
19
+ color: text('color'),
18
20
  tenantId: uuid('tenant_id'),
19
21
  actorClass: text('actor_class'),
20
22
  actorId: text('actor_id'),
@@ -91,6 +93,8 @@ export const reportingAnalytics = pgTable('reporting_analytics', {
91
93
  .notNull()
92
94
  .default(sql `'{}'::jsonb`),
93
95
  schemaVersion: smallint('schema_version').notNull().default(1),
96
+ release: text('release'),
97
+ color: text('color'),
94
98
  }, (t) => [
95
99
  index('reporting_analytics_site_time_idx').on(t.site, t.occurredAt.desc(), t.id.desc()),
96
100
  index('reporting_analytics_tenant_time_idx')
@@ -162,6 +166,7 @@ export const reportingErrors = pgTable('reporting_errors', {
162
166
  stack: text('stack'),
163
167
  runtime: text('runtime').notNull(),
164
168
  release: text('release'),
169
+ color: text('color'),
165
170
  firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(),
166
171
  lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(),
167
172
  occurrences: bigint('occurrences', { mode: 'number' }).notNull().default(1),
@@ -176,6 +181,31 @@ export const reportingErrors = pgTable('reporting_errors', {
176
181
  .where(sql `${t.state} = 'open'`),
177
182
  index('reporting_errors_state_idx').on(t.state, t.lastSeenAt.desc()),
178
183
  ]);
184
+ /** One row per (fingerprint, tenant): that tenant's own occurrences of a shared error group; mirrored from migrations/0008_error_tenants.sql. */
185
+ export const reportingErrorTenants = pgTable('reporting_error_tenants', {
186
+ fingerprint: text('fingerprint').notNull(),
187
+ tenantId: uuid('tenant_id').notNull(),
188
+ occurrences: bigint('occurrences', { mode: 'number' }).notNull().default(1),
189
+ firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(),
190
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(),
191
+ }, (t) => [
192
+ primaryKey({ name: 'reporting_error_tenants_pkey', columns: [t.fingerprint, t.tenantId] }),
193
+ index('reporting_error_tenants_tenant_idx').on(t.tenantId, t.lastSeenAt.desc()),
194
+ ]);
195
+ /** Request and 5xx counts per minute, color and release; mirrored from migrations/0010_requests.sql. */
196
+ export const reportingRequests = pgTable('reporting_requests', {
197
+ bucket: timestamp('bucket', { withTimezone: true }).notNull(),
198
+ site: text('site').notNull(),
199
+ color: text('color').notNull().default(''),
200
+ release: text('release').notNull().default(''),
201
+ requests: bigint('requests', { mode: 'number' }).notNull().default(0),
202
+ errors: bigint('errors', { mode: 'number' }).notNull().default(0),
203
+ }, (t) => [
204
+ primaryKey({
205
+ name: 'reporting_requests_pkey',
206
+ columns: [t.bucket, t.site, t.color, t.release],
207
+ }),
208
+ ]);
179
209
  export const tables = {
180
210
  events: reportingEvents,
181
211
  tasks: reportingTasks,
@@ -185,4 +215,6 @@ export const tables = {
185
215
  analyticsDaily: reportingAnalyticsDaily,
186
216
  analyticsWeekly: reportingAnalyticsWeekly,
187
217
  errors: reportingErrors,
218
+ errorTenants: reportingErrorTenants,
219
+ requests: reportingRequests,
188
220
  };
package/dist/types.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { RecentEvent, RecentOptions, SeriesOptions, SeriesPoint, WeeklyPoin
3
3
  import type { Device } from './analytics/schema.js';
4
4
  import type { Actor, ErrorRuntime, ErrorState, EventInput, FlatData, Level } from './schema.js';
5
5
  import type { TenantRetentionKey } from './settings.js';
6
- import type { ReportingErrorRow, ReportingEventRow, tables } from './tables.js';
6
+ import type { ReportingErrorRow, ReportingEventRow, TenantErrorRow, tables } from './tables.js';
7
7
  /**
8
8
  * The host's drizzle handle, whatever driver it runs on. postgres-js in the
9
9
  * apps, PGlite in this package's own tests; the queries here use nothing
@@ -81,6 +81,8 @@ export interface ReportingOptions {
81
81
  batchSize?: number;
82
82
  /** Timer flush interval, ms. Default 1,000. */
83
83
  flushEveryMs?: number;
84
+ /** How often counted requests are written, ms. Default 10,000. */
85
+ requestFlushEveryMs?: number;
84
86
  }
85
87
  export interface EventsPageOptions {
86
88
  readonly limit?: number;
@@ -111,12 +113,14 @@ export interface ErrorsPageOptions {
111
113
  readonly site?: string;
112
114
  readonly state?: ErrorState;
113
115
  readonly runtime?: ErrorRuntime;
114
- /** A case-insensitive substring match against `message` or `stack`, for triaging an incident by grepping the group list. Capped at 200 characters. */
116
+ /** A case-insensitive substring match against `message` or `stack`, for triaging an incident by grepping the group list. Capped at 200 characters. With `tenantId`, it matches `kind` only: the shared message and stack are not the tenant's to search. */
115
117
  readonly search?: string;
116
118
  readonly environment?: string;
119
+ /** Only groups this tenant hit, as `TenantErrorRow`s: `occurrences`, `firstSeenAt`, `lastSeenAt` and `tenantId` come from the tenant's own record (migration 0008), and `message`, `stack`, `runtime`, `release`, `requestId`, `resolvedBy`, `state` and `resolvedAt` are null, because the shared group holds them from whichever tenant hit it last. `state` is ignored as a filter in tenant scope. Scope is decided by presence: an absent key is unscoped, any present value, `undefined` included, must be a uuid or the call throws. */
120
+ readonly tenantId?: string;
117
121
  }
118
- export interface ErrorsPage {
119
- readonly items: readonly ReportingErrorRow[];
122
+ export interface ErrorsPage<Row = ReportingErrorRow | TenantErrorRow> {
123
+ readonly items: readonly Row[];
120
124
  readonly next: {
121
125
  readonly lastSeenAt: Date;
122
126
  readonly fingerprint: string;
@@ -196,6 +200,10 @@ export interface Reporting {
196
200
  readonly site: string;
197
201
  readonly log: Logger;
198
202
  readonly analytics: Analytics;
203
+ /** Counts one response for the release-health read; `flush` writes the counts. Never throws. */
204
+ readonly requests: {
205
+ record(status: number): void;
206
+ };
199
207
  stats(): Stats;
200
208
  }
201
209
  export type { Actor, EventInput, FlatData, Level };
package/dist/writer.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { deployTags } from './deploy.js';
1
2
  import { eventInputSchema } from './schema.js';
2
3
  import { reportingEvents } from './tables.js';
3
4
  export class Writer {
@@ -55,6 +56,7 @@ export class Writer {
55
56
  kind: v.kind,
56
57
  site: this.o.site,
57
58
  environment: this.o.environment,
59
+ ...deployTags(),
58
60
  tenantId: v.tenantId ?? null,
59
61
  actorClass: v.actor?.class ?? null,
60
62
  actorId: v.actor?.id ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/reporting",
3
- "version": "0.5.1",
3
+ "version": "0.8.1",
4
4
  "description": "The estate's operational event log and housekeeping tick: a writer that never fails a request, a claim protocol that needs no scheduler, and the readers an operator opens. Per-app Postgres, host-supplied logger, framework-neutral core with a Next entry.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,10 +26,6 @@
26
26
  "types": "./dist/next/index.d.ts",
27
27
  "default": "./dist/next/index.js"
28
28
  },
29
- "./fake": {
30
- "types": "./dist/fake/index.d.ts",
31
- "default": "./dist/fake/index.js"
32
- },
33
29
  "./browser": {
34
30
  "types": "./dist/browser/index.d.ts",
35
31
  "default": "./dist/browser/index.js"
@@ -46,6 +42,10 @@
46
42
  "types": "./dist/react/errors.d.ts",
47
43
  "default": "./dist/react/errors.js"
48
44
  },
45
+ "./migrations-dir": {
46
+ "types": "./dist/migrations-dir.d.ts",
47
+ "default": "./dist/migrations-dir.js"
48
+ },
49
49
  "./migrations/*.sql": "./dist/migrations/*.sql",
50
50
  "./package.json": "./package.json"
51
51
  },
@@ -63,7 +63,7 @@
63
63
  "test": "vitest run"
64
64
  },
65
65
  "peerDependencies": {
66
- "drizzle-orm": ">=0.39.0",
66
+ "drizzle-orm": ">=0.39.3 <1.0.0",
67
67
  "next": ">=16.0.0",
68
68
  "react": ">=19.0.0",
69
69
  "zod": "^4.0.0"
@@ -77,9 +77,9 @@
77
77
  "@electric-sql/pglite": "^0.5.8",
78
78
  "@types/node": "^22",
79
79
  "@types/react": "^19",
80
- "drizzle-orm": "^0.39.3",
80
+ "@wtfalch/db": "0.5.2",
81
+ "drizzle-orm": "0.39.3",
81
82
  "next": "^16.2.4",
82
- "postgres": "^3.4.5",
83
83
  "react": "^19",
84
84
  "react-dom": "^19",
85
85
  "typescript": "^5.9.0",
@@ -1,22 +0,0 @@
1
- import type { ReportingEventRow } from '../tables.js';
2
- import type { AlertFinding, Logger, Reporting, TrackInput } from '../types.js';
3
- /**
4
- * An in-memory `Reporting` for a host's unit tests: same validation, same
5
- * shape, no database. `rows` is the table; `alerts` is what `onAlert` would
6
- * have received. Validation failures throw, as in development.
7
- */
8
- export interface FakeReporting extends Reporting {
9
- readonly rows: ReportingEventRow[];
10
- readonly alerts: AlertFinding[];
11
- /** What `analytics.track` received. */
12
- readonly tracked: (TrackInput & {
13
- site: string;
14
- })[];
15
- reset(): void;
16
- }
17
- export declare function createFakeReporting(opts?: {
18
- site?: string;
19
- log?: Logger;
20
- redactEnvVars?: readonly string[];
21
- environment?: string | null;
22
- }): FakeReporting;
@@ -1,157 +0,0 @@
1
- import { alertRow } from '../alerts.js';
2
- import { createCapture } from '../errors/capture.js';
3
- import { eventInputSchema } from '../schema.js';
4
- import { DEFAULT_SETTINGS, RETENTION_BOUNDS } from '../settings.js';
5
- import { tables } from '../tables.js';
6
- const silent = { info() { }, warn() { }, error() { } };
7
- export function createFakeReporting(opts = {}) {
8
- const site = opts.site ?? 'test';
9
- const environment = opts.environment ?? null;
10
- const log = opts.log ?? silent;
11
- const rows = [];
12
- const alerts = [];
13
- const tracked = [];
14
- let settings = { ...DEFAULT_SETTINGS };
15
- const tenantRetention = new Map();
16
- let id = 0;
17
- // No group table to fake, so the upsert never runs (`defer` is a no-op);
18
- // the timeline row through `fake.event` is what a host's test asserts on.
19
- const capture = createCapture({
20
- db: undefined,
21
- log,
22
- site,
23
- now: () => new Date(),
24
- defer: () => { },
25
- event: (input) => fake.event(input),
26
- redactEnvVars: opts.redactEnvVars,
27
- });
28
- const fake = {
29
- site,
30
- log,
31
- tables,
32
- rows,
33
- alerts,
34
- tracked,
35
- event(input) {
36
- const v = eventInputSchema.parse(input);
37
- id += 1;
38
- rows.push({
39
- id,
40
- occurredAt: v.occurredAt ?? new Date(),
41
- level: v.level,
42
- kind: v.kind,
43
- kindNs: v.kind.split('.')[0] ?? '',
44
- site,
45
- environment,
46
- tenantId: v.tenantId ?? null,
47
- actorClass: v.actor?.class ?? null,
48
- actorId: v.actor?.id ?? null,
49
- requestId: v.requestId ?? null,
50
- targetType: v.target?.type ?? null,
51
- targetId: v.target?.id ?? null,
52
- message: v.message,
53
- data: v.data,
54
- schemaVersion: 1,
55
- });
56
- },
57
- alert(finding) {
58
- fake.event(alertRow(finding));
59
- alerts.push(finding);
60
- },
61
- captureError: (error, context) => capture(error, context),
62
- async flush() { },
63
- events: {
64
- async page(o = {}) {
65
- const limit = Math.max(1, Math.min(o.limit ?? 50, 200));
66
- let list = [...rows].sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime() || b.id - a.id);
67
- if (o.level)
68
- list = list.filter((r) => r.level === o.level);
69
- if (o.kindNs)
70
- list = list.filter((r) => r.kindNs === o.kindNs);
71
- if (o.tenantId)
72
- list = list.filter((r) => r.tenantId === o.tenantId);
73
- if (o.site)
74
- list = list.filter((r) => r.site === o.site);
75
- if (o.requestId)
76
- list = list.filter((r) => r.requestId === o.requestId);
77
- if (o.environment)
78
- list = list.filter((r) => r.environment === o.environment);
79
- if (o.after) {
80
- const { occurredAt, id: afterId } = o.after;
81
- list = list.filter((r) => r.occurredAt.getTime() < occurredAt.getTime() ||
82
- (r.occurredAt.getTime() === occurredAt.getTime() && r.id < afterId));
83
- }
84
- const items = list.slice(0, limit);
85
- const last = items.at(-1);
86
- return {
87
- items,
88
- next: list.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null,
89
- };
90
- },
91
- },
92
- settings: {
93
- async get() {
94
- return settings;
95
- },
96
- async set(patch, by) {
97
- const before = settings;
98
- for (const [key, value] of Object.entries(patch)) {
99
- if (typeof value !== 'number' ||
100
- !Number.isInteger(value) ||
101
- value < RETENTION_BOUNDS.min ||
102
- value > RETENTION_BOUNDS.max) {
103
- throw new Error(`reporting.settings: ${key} out of bounds`);
104
- }
105
- }
106
- settings = { ...settings, ...patch };
107
- const changed = Object.keys(patch).filter((k) => before[k] !== settings[k]);
108
- return { before, after: settings, by, changed };
109
- },
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
- },
133
- analytics: {
134
- async track(input) {
135
- tracked.push({ ...input, site });
136
- },
137
- async series() {
138
- return [];
139
- },
140
- async weekly() {
141
- return [];
142
- },
143
- async recent() {
144
- return [];
145
- },
146
- },
147
- stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length }),
148
- reset() {
149
- rows.length = 0;
150
- alerts.length = 0;
151
- settings = { ...DEFAULT_SETTINGS };
152
- tenantRetention.clear();
153
- id = 0;
154
- },
155
- };
156
- return fake;
157
- }