@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/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
|
|
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.
|
|
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
|
-
"
|
|
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",
|
package/dist/fake/index.d.ts
DELETED
|
@@ -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;
|
package/dist/fake/index.js
DELETED
|
@@ -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
|
-
}
|