@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.
Files changed (51) hide show
  1. package/README.md +56 -0
  2. package/dist/analytics/collector.d.ts +40 -0
  3. package/dist/analytics/collector.js +146 -0
  4. package/dist/analytics/reader.d.ts +63 -0
  5. package/dist/analytics/reader.js +89 -0
  6. package/dist/analytics/schema.d.ts +59 -0
  7. package/dist/analytics/schema.js +126 -0
  8. package/dist/analytics/tasks.d.ts +32 -0
  9. package/dist/analytics/tasks.js +199 -0
  10. package/dist/analytics/write.d.ts +19 -0
  11. package/dist/analytics/write.js +20 -0
  12. package/dist/browser/index.d.ts +53 -0
  13. package/dist/browser/index.js +330 -0
  14. package/dist/errors/capture.d.ts +52 -0
  15. package/dist/errors/capture.js +187 -0
  16. package/dist/errors/fingerprint.d.ts +36 -0
  17. package/dist/errors/fingerprint.js +211 -0
  18. package/dist/errors/reader.d.ts +19 -0
  19. package/dist/errors/reader.js +66 -0
  20. package/dist/errors/redact.d.ts +46 -0
  21. package/dist/errors/redact.js +93 -0
  22. package/dist/errors/tasks.d.ts +16 -0
  23. package/dist/errors/tasks.js +50 -0
  24. package/dist/fake/index.d.ts +5 -1
  25. package/dist/fake/index.js +28 -0
  26. package/dist/housekeeping/index.d.ts +5 -0
  27. package/dist/housekeeping/index.js +18 -6
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +61 -5
  30. package/dist/ingest-limit.d.ts +22 -0
  31. package/dist/ingest-limit.js +39 -0
  32. package/dist/migrations/0002_analytics.sql +350 -0
  33. package/dist/migrations/0003_errors.sql +118 -0
  34. package/dist/next/client-errors.d.ts +46 -0
  35. package/dist/next/client-errors.js +165 -0
  36. package/dist/next/index.d.ts +55 -0
  37. package/dist/next/index.js +72 -0
  38. package/dist/react/charts.d.ts +24 -0
  39. package/dist/react/charts.js +45 -0
  40. package/dist/react/errors.d.ts +12 -0
  41. package/dist/react/errors.js +17 -0
  42. package/dist/react/index.d.ts +21 -0
  43. package/dist/react/index.js +52 -0
  44. package/dist/schema.d.ts +8 -0
  45. package/dist/schema.js +8 -0
  46. package/dist/settings.d.ts +1 -0
  47. package/dist/settings.js +15 -8
  48. package/dist/tables.d.ts +2287 -253
  49. package/dist/tables.js +111 -1
  50. package/dist/types.d.ts +70 -2
  51. 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 { Actor, EventInput, FlatData, Level } from './schema.js';
3
- import type { ReportingEventRow, tables } from './tables.js';
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.1.2",
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",