@wtfalch/reporting 0.1.2 → 0.3.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/README.md +56 -0
- package/dist/analytics/collector.d.ts +40 -0
- package/dist/analytics/collector.js +146 -0
- package/dist/analytics/reader.d.ts +63 -0
- package/dist/analytics/reader.js +89 -0
- package/dist/analytics/schema.d.ts +59 -0
- package/dist/analytics/schema.js +126 -0
- package/dist/analytics/tasks.d.ts +32 -0
- package/dist/analytics/tasks.js +199 -0
- package/dist/analytics/write.d.ts +19 -0
- package/dist/analytics/write.js +20 -0
- package/dist/browser/index.d.ts +53 -0
- package/dist/browser/index.js +330 -0
- package/dist/errors/capture.d.ts +52 -0
- package/dist/errors/capture.js +187 -0
- package/dist/errors/fingerprint.d.ts +36 -0
- package/dist/errors/fingerprint.js +211 -0
- package/dist/errors/reader.d.ts +19 -0
- package/dist/errors/reader.js +66 -0
- package/dist/errors/redact.d.ts +46 -0
- package/dist/errors/redact.js +93 -0
- package/dist/errors/tasks.d.ts +16 -0
- package/dist/errors/tasks.js +50 -0
- package/dist/fake/index.d.ts +5 -1
- package/dist/fake/index.js +28 -0
- package/dist/housekeeping/index.d.ts +5 -0
- package/dist/housekeeping/index.js +18 -6
- package/dist/index.d.ts +5 -1
- package/dist/index.js +61 -5
- package/dist/ingest-limit.d.ts +22 -0
- package/dist/ingest-limit.js +39 -0
- package/dist/migrations/0002_analytics.sql +350 -0
- package/dist/migrations/0003_errors.sql +118 -0
- package/dist/next/client-errors.d.ts +46 -0
- package/dist/next/client-errors.js +165 -0
- package/dist/next/index.d.ts +55 -0
- package/dist/next/index.js +72 -0
- package/dist/react/charts.d.ts +24 -0
- package/dist/react/charts.js +45 -0
- package/dist/react/errors.d.ts +12 -0
- package/dist/react/errors.js +17 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +52 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +8 -0
- package/dist/settings.d.ts +1 -0
- package/dist/settings.js +15 -8
- package/dist/tables.d.ts +2287 -253
- package/dist/tables.js +111 -1
- package/dist/types.d.ts +70 -2
- package/package.json +19 -1
package/dist/tables.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
|
-
import { bigint, index, jsonb, pgTable, smallint, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { bigint, date, index, integer, jsonb, pgTable, primaryKey, smallint, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
|
|
3
3
|
/**
|
|
4
4
|
* The three tables, mirrored from `migrations/0001_reporting.sql`, which is
|
|
5
5
|
* the source of truth: the SQL carries the CHECKs, the partial indexes, the
|
|
@@ -48,6 +48,8 @@ export const reportingTasks = pgTable('reporting_tasks', {
|
|
|
48
48
|
claimToken: uuid('claim_token'),
|
|
49
49
|
lastOutcome: text('last_outcome'),
|
|
50
50
|
lastError: text('last_error'),
|
|
51
|
+
/** The rollups' completed-history watermark (0002), one value per task. */
|
|
52
|
+
watermark: text('watermark'),
|
|
51
53
|
});
|
|
52
54
|
export const reportingSettings = pgTable('reporting_settings', {
|
|
53
55
|
key: text('key').primaryKey(),
|
|
@@ -55,8 +57,116 @@ export const reportingSettings = pgTable('reporting_settings', {
|
|
|
55
57
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
56
58
|
updatedBy: text('updated_by'),
|
|
57
59
|
});
|
|
60
|
+
/** Raw analytics rows, a window's worth; mirrored from migrations/0002_analytics.sql. */
|
|
61
|
+
export const reportingAnalytics = pgTable('reporting_analytics', {
|
|
62
|
+
id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
|
|
63
|
+
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
|
64
|
+
receivedAt: timestamp('received_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
65
|
+
site: text('site').notNull(),
|
|
66
|
+
tenantId: uuid('tenant_id'),
|
|
67
|
+
visitorId: text('visitor_id'),
|
|
68
|
+
sessionId: text('session_id'),
|
|
69
|
+
userId: text('user_id'),
|
|
70
|
+
name: text('name').notNull(),
|
|
71
|
+
path: text('path').notNull(),
|
|
72
|
+
referrerHost: text('referrer_host'),
|
|
73
|
+
device: text('device').notNull(),
|
|
74
|
+
country: text('country'),
|
|
75
|
+
props: jsonb('props')
|
|
76
|
+
.$type()
|
|
77
|
+
.notNull()
|
|
78
|
+
.default(sql `'{}'::jsonb`),
|
|
79
|
+
schemaVersion: smallint('schema_version').notNull().default(1),
|
|
80
|
+
}, (t) => [
|
|
81
|
+
index('reporting_analytics_site_time_idx').on(t.site, t.occurredAt.desc(), t.id.desc()),
|
|
82
|
+
index('reporting_analytics_tenant_time_idx')
|
|
83
|
+
.on(t.tenantId, t.occurredAt.desc(), t.id.desc())
|
|
84
|
+
.where(sql `${t.tenantId} is not null`),
|
|
85
|
+
index('reporting_analytics_user_idx').on(t.userId).where(sql `${t.userId} is not null`),
|
|
86
|
+
index('reporting_analytics_site_name_time_idx').on(t.site, t.name, t.occurredAt.desc()),
|
|
87
|
+
]);
|
|
88
|
+
/** One row per day, site, grain and dimension value; written only by reporting_rollup_day. */
|
|
89
|
+
export const reportingAnalyticsDaily = pgTable('reporting_analytics_daily', {
|
|
90
|
+
day: date('day').notNull(),
|
|
91
|
+
site: text('site').notNull(),
|
|
92
|
+
grain: text('grain').notNull(),
|
|
93
|
+
tenantId: uuid('tenant_id'),
|
|
94
|
+
name: text('name'),
|
|
95
|
+
path: text('path'),
|
|
96
|
+
device: text('device'),
|
|
97
|
+
country: text('country'),
|
|
98
|
+
referrerHost: text('referrer_host'),
|
|
99
|
+
views: integer('views').notNull(),
|
|
100
|
+
visitors: integer('visitors').notNull(),
|
|
101
|
+
people: integer('people').notNull(),
|
|
102
|
+
tenantKey: text('tenant_key').generatedAlwaysAs(sql `coalesce(tenant_id::text, '')`),
|
|
103
|
+
nameKey: text('name_key').generatedAlwaysAs(sql `coalesce(name, '')`),
|
|
104
|
+
pathKey: text('path_key').generatedAlwaysAs(sql `coalesce(path, '')`),
|
|
105
|
+
deviceKey: text('device_key').generatedAlwaysAs(sql `coalesce(device, '')`),
|
|
106
|
+
countryKey: text('country_key').generatedAlwaysAs(sql `coalesce(country, '')`),
|
|
107
|
+
referrerKey: text('referrer_key').generatedAlwaysAs(sql `coalesce(referrer_host, '')`),
|
|
108
|
+
}, (t) => [
|
|
109
|
+
primaryKey({
|
|
110
|
+
name: 'reporting_analytics_daily_pkey',
|
|
111
|
+
columns: [
|
|
112
|
+
t.day,
|
|
113
|
+
t.site,
|
|
114
|
+
t.grain,
|
|
115
|
+
t.tenantKey,
|
|
116
|
+
t.nameKey,
|
|
117
|
+
t.pathKey,
|
|
118
|
+
t.deviceKey,
|
|
119
|
+
t.countryKey,
|
|
120
|
+
t.referrerKey,
|
|
121
|
+
],
|
|
122
|
+
}),
|
|
123
|
+
index('reporting_analytics_daily_site_grain_idx').on(t.site, t.grain, t.day.desc()),
|
|
124
|
+
]);
|
|
125
|
+
/** Visitors and people per ISO week and how many returned; written only by reporting_rollup_week. */
|
|
126
|
+
export const reportingAnalyticsWeekly = pgTable('reporting_analytics_weekly', {
|
|
127
|
+
week: date('week').notNull(),
|
|
128
|
+
site: text('site').notNull(),
|
|
129
|
+
tenantId: uuid('tenant_id'),
|
|
130
|
+
visitors: integer('visitors').notNull(),
|
|
131
|
+
returningVisitors: integer('returning_visitors').notNull(),
|
|
132
|
+
people: integer('people').notNull(),
|
|
133
|
+
returningPeople: integer('returning_people').notNull(),
|
|
134
|
+
tenantKey: text('tenant_key').generatedAlwaysAs(sql `coalesce(tenant_id::text, '')`),
|
|
135
|
+
}, (t) => [
|
|
136
|
+
primaryKey({
|
|
137
|
+
name: 'reporting_analytics_weekly_pkey',
|
|
138
|
+
columns: [t.week, t.site, t.tenantKey],
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
/** One row per fingerprint; mirrored from migrations/0003_errors.sql. */
|
|
142
|
+
export const reportingErrors = pgTable('reporting_errors', {
|
|
143
|
+
fingerprint: text('fingerprint').primaryKey(),
|
|
144
|
+
site: text('site').notNull(),
|
|
145
|
+
kind: text('kind').notNull(),
|
|
146
|
+
message: text('message').notNull(),
|
|
147
|
+
stack: text('stack'),
|
|
148
|
+
runtime: text('runtime').notNull(),
|
|
149
|
+
release: text('release'),
|
|
150
|
+
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(),
|
|
151
|
+
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(),
|
|
152
|
+
occurrences: bigint('occurrences', { mode: 'number' }).notNull().default(1),
|
|
153
|
+
state: text('state').notNull().default('open'),
|
|
154
|
+
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
|
155
|
+
resolvedBy: text('resolved_by'),
|
|
156
|
+
tenantId: uuid('tenant_id'),
|
|
157
|
+
requestId: text('request_id'),
|
|
158
|
+
}, (t) => [
|
|
159
|
+
index('reporting_errors_site_open_idx')
|
|
160
|
+
.on(t.site, t.lastSeenAt.desc())
|
|
161
|
+
.where(sql `${t.state} = 'open'`),
|
|
162
|
+
index('reporting_errors_state_idx').on(t.state, t.lastSeenAt.desc()),
|
|
163
|
+
]);
|
|
58
164
|
export const tables = {
|
|
59
165
|
events: reportingEvents,
|
|
60
166
|
tasks: reportingTasks,
|
|
61
167
|
settings: reportingSettings,
|
|
168
|
+
analytics: reportingAnalytics,
|
|
169
|
+
analyticsDaily: reportingAnalyticsDaily,
|
|
170
|
+
analyticsWeekly: reportingAnalyticsWeekly,
|
|
171
|
+
errors: reportingErrors,
|
|
62
172
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
2
|
+
import type { RecentEvent, RecentOptions, SeriesOptions, SeriesPoint, WeeklyPoint } from './analytics/reader.js';
|
|
3
|
+
import type { Device } from './analytics/schema.js';
|
|
4
|
+
import type { Actor, ErrorRuntime, ErrorState, EventInput, FlatData, Level } from './schema.js';
|
|
5
|
+
import type { ReportingErrorRow, ReportingEventRow, tables } from './tables.js';
|
|
4
6
|
/**
|
|
5
7
|
* The host's drizzle handle, whatever driver it runs on. postgres-js in the
|
|
6
8
|
* apps, PGlite in this package's own tests; the queries here use nothing
|
|
@@ -27,8 +29,12 @@ export interface SettingsChange {
|
|
|
27
29
|
readonly by: Actor;
|
|
28
30
|
readonly changed: readonly string[];
|
|
29
31
|
}
|
|
32
|
+
export type ConsentMode = 'none' | 'consented' | 'always';
|
|
30
33
|
export interface Settings {
|
|
31
34
|
readonly 'events.retention_days': number;
|
|
35
|
+
readonly 'analytics.retention_days': number;
|
|
36
|
+
readonly 'analytics.consent': ConsentMode;
|
|
37
|
+
readonly 'analytics.identify_signed_in': boolean;
|
|
32
38
|
}
|
|
33
39
|
export interface ReportingOptions {
|
|
34
40
|
db: Db;
|
|
@@ -48,6 +54,8 @@ export interface ReportingOptions {
|
|
|
48
54
|
onAlert?: (finding: AlertFinding) => void;
|
|
49
55
|
/** Bound by the host to its audit ledger; called after a settings change commits. */
|
|
50
56
|
audit?: (change: SettingsChange) => Promise<void>;
|
|
57
|
+
/** The host's route normalisation for analytics paths (addendum A7); the package's default otherwise. */
|
|
58
|
+
normalisePath?: (pathname: string) => string;
|
|
51
59
|
/** Queue bound; past it rows go to the logger only. Default 1,000. */
|
|
52
60
|
queueLimit?: number;
|
|
53
61
|
/** Rows per insert. Default 500. */
|
|
@@ -74,17 +82,76 @@ export interface EventsPage {
|
|
|
74
82
|
readonly id: number;
|
|
75
83
|
} | null;
|
|
76
84
|
}
|
|
85
|
+
export interface ErrorsPageOptions {
|
|
86
|
+
readonly limit?: number;
|
|
87
|
+
readonly after?: {
|
|
88
|
+
readonly lastSeenAt: Date;
|
|
89
|
+
readonly fingerprint: string;
|
|
90
|
+
};
|
|
91
|
+
readonly site?: string;
|
|
92
|
+
readonly state?: ErrorState;
|
|
93
|
+
readonly runtime?: ErrorRuntime;
|
|
94
|
+
}
|
|
95
|
+
export interface ErrorsPage {
|
|
96
|
+
readonly items: readonly ReportingErrorRow[];
|
|
97
|
+
readonly next: {
|
|
98
|
+
readonly lastSeenAt: Date;
|
|
99
|
+
readonly fingerprint: string;
|
|
100
|
+
} | null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* What `captureError` knows about an exception's surroundings; every field
|
|
104
|
+
* is optional because a boot-time crash has none of them.
|
|
105
|
+
*/
|
|
106
|
+
export interface CaptureContext {
|
|
107
|
+
readonly runtime?: ErrorRuntime;
|
|
108
|
+
readonly tenantId?: string | null;
|
|
109
|
+
readonly requestId?: string | null;
|
|
110
|
+
readonly release?: string | null;
|
|
111
|
+
/** Overrides `errorKind(error)` when the caller already knows the class name better than the thrown value can say — a JSON-serialised browser report, say. */
|
|
112
|
+
readonly kind?: string;
|
|
113
|
+
}
|
|
77
114
|
export interface Stats {
|
|
78
115
|
readonly queued: number;
|
|
79
116
|
readonly dropped: number;
|
|
80
117
|
readonly invalid: number;
|
|
81
118
|
readonly flushed: number;
|
|
82
119
|
}
|
|
120
|
+
/** A server-side analytics event: the server's truth about something a person did, with identity from the `Access`, never a claim. */
|
|
121
|
+
export interface TrackInput {
|
|
122
|
+
readonly name: string;
|
|
123
|
+
readonly props?: Record<string, string | number | boolean | null>;
|
|
124
|
+
readonly tenantId?: string | null;
|
|
125
|
+
/** The signed-in person's id, from the host's resolved principal. */
|
|
126
|
+
readonly userId?: string | null;
|
|
127
|
+
/** The pathname the event belongs to; normalised before storage. Default `/`. */
|
|
128
|
+
readonly path?: string;
|
|
129
|
+
readonly device?: Device;
|
|
130
|
+
readonly country?: string | null;
|
|
131
|
+
/** The consent cookie's value, when the host passes it through. */
|
|
132
|
+
readonly visitorId?: string | null;
|
|
133
|
+
readonly sessionId?: string | null;
|
|
134
|
+
readonly occurredAt?: Date;
|
|
135
|
+
}
|
|
136
|
+
export interface Analytics {
|
|
137
|
+
/** Never throws in production: a failed insert is a log line. */
|
|
138
|
+
track(input: TrackInput): Promise<void>;
|
|
139
|
+
series(opts: SeriesOptions): Promise<readonly SeriesPoint[]>;
|
|
140
|
+
weekly(opts: {
|
|
141
|
+
site: string;
|
|
142
|
+
tenantId?: string;
|
|
143
|
+
from: string;
|
|
144
|
+
to: string;
|
|
145
|
+
}): Promise<readonly WeeklyPoint[]>;
|
|
146
|
+
recent(opts: RecentOptions): Promise<readonly RecentEvent[]>;
|
|
147
|
+
}
|
|
83
148
|
export interface Reporting {
|
|
84
149
|
/** Never throws in production; see writer.ts. */
|
|
85
150
|
event(input: EventInput): void;
|
|
86
151
|
/** An `alert.<check>` row at level `alert`, then the host's `onAlert`. */
|
|
87
152
|
alert(finding: AlertFinding): void;
|
|
153
|
+
/** An uncaught exception, redacted and fingerprinted (./errors/capture.js). Never throws. */
|
|
154
|
+
captureError(error: unknown, context?: CaptureContext): void;
|
|
88
155
|
/** Drain the queue now. Bounded by `deadlineMs` (default 5,000). Never throws. */
|
|
89
156
|
flush(opts?: {
|
|
90
157
|
deadlineMs?: number;
|
|
@@ -99,6 +166,7 @@ export interface Reporting {
|
|
|
99
166
|
readonly tables: typeof tables;
|
|
100
167
|
readonly site: string;
|
|
101
168
|
readonly log: Logger;
|
|
169
|
+
readonly analytics: Analytics;
|
|
102
170
|
stats(): Stats;
|
|
103
171
|
}
|
|
104
172
|
export type { Actor, EventInput, FlatData, Level };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/reporting",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -30,6 +30,22 @@
|
|
|
30
30
|
"types": "./dist/fake/index.d.ts",
|
|
31
31
|
"default": "./dist/fake/index.js"
|
|
32
32
|
},
|
|
33
|
+
"./browser": {
|
|
34
|
+
"types": "./dist/browser/index.d.ts",
|
|
35
|
+
"default": "./dist/browser/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./react": {
|
|
38
|
+
"types": "./dist/react/index.d.ts",
|
|
39
|
+
"default": "./dist/react/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./react/charts": {
|
|
42
|
+
"types": "./dist/react/charts.d.ts",
|
|
43
|
+
"default": "./dist/react/charts.js"
|
|
44
|
+
},
|
|
45
|
+
"./react/errors": {
|
|
46
|
+
"types": "./dist/react/errors.d.ts",
|
|
47
|
+
"default": "./dist/react/errors.js"
|
|
48
|
+
},
|
|
33
49
|
"./migrations/*.sql": "./dist/migrations/*.sql",
|
|
34
50
|
"./package.json": "./package.json"
|
|
35
51
|
},
|
|
@@ -49,6 +65,7 @@
|
|
|
49
65
|
"peerDependencies": {
|
|
50
66
|
"drizzle-orm": ">=0.39.0",
|
|
51
67
|
"next": ">=16.0.0",
|
|
68
|
+
"react": ">=19.0.0",
|
|
52
69
|
"zod": "^4.0.0"
|
|
53
70
|
},
|
|
54
71
|
"peerDependenciesMeta": {
|
|
@@ -59,6 +76,7 @@
|
|
|
59
76
|
"devDependencies": {
|
|
60
77
|
"@electric-sql/pglite": "^0.5.8",
|
|
61
78
|
"@types/node": "^22",
|
|
79
|
+
"@types/react": "^19",
|
|
62
80
|
"drizzle-orm": "^0.39.3",
|
|
63
81
|
"next": "^16.2.4",
|
|
64
82
|
"postgres": "^3.4.5",
|