@wtfalch/reporting 0.1.1 → 0.2.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/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
@@ -21,15 +21,23 @@ export const reportingEvents = pgTable('reporting_events', {
21
21
  targetType: text('target_type'),
22
22
  targetId: text('target_id'),
23
23
  message: text('message').notNull(),
24
- data: jsonb('data').$type().notNull(),
24
+ data: jsonb('data')
25
+ .$type()
26
+ .notNull()
27
+ .default(sql `'{}'::jsonb`),
25
28
  schemaVersion: smallint('schema_version').notNull().default(1),
26
29
  }, (t) => [
27
30
  index('reporting_events_time_idx').on(t.occurredAt.desc(), t.id.desc()),
28
- // The partial predicates live in the SQL; these declare the columns.
29
- index('reporting_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
31
+ index('reporting_events_tenant_time_idx')
32
+ .on(t.tenantId, t.occurredAt.desc(), t.id.desc())
33
+ .where(sql `${t.tenantId} is not null`),
30
34
  index('reporting_events_ns_time_idx').on(t.kindNs, t.occurredAt.desc(), t.id.desc()),
31
- index('reporting_events_problem_time_idx').on(t.occurredAt.desc(), t.id.desc()),
32
- index('reporting_events_request_idx').on(t.requestId, t.occurredAt.desc()),
35
+ index('reporting_events_problem_time_idx')
36
+ .on(t.occurredAt.desc(), t.id.desc())
37
+ .where(sql `${t.level} in ('error', 'alert')`),
38
+ index('reporting_events_request_idx')
39
+ .on(t.requestId, t.occurredAt.desc())
40
+ .where(sql `${t.requestId} is not null`),
33
41
  ]);
34
42
  export const reportingTasks = pgTable('reporting_tasks', {
35
43
  task: text('task').primaryKey(),
@@ -40,6 +48,7 @@ export const reportingTasks = pgTable('reporting_tasks', {
40
48
  claimToken: uuid('claim_token'),
41
49
  lastOutcome: text('last_outcome'),
42
50
  lastError: text('last_error'),
51
+ /** The rollups' completed-history watermark (0002), one value per task. */
43
52
  watermark: text('watermark'),
44
53
  });
45
54
  export const reportingSettings = pgTable('reporting_settings', {
@@ -48,8 +57,92 @@ export const reportingSettings = pgTable('reporting_settings', {
48
57
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
49
58
  updatedBy: text('updated_by'),
50
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
+ ]);
51
141
  export const tables = {
52
142
  events: reportingEvents,
53
143
  tasks: reportingTasks,
54
144
  settings: reportingSettings,
145
+ analytics: reportingAnalytics,
146
+ analyticsDaily: reportingAnalyticsDaily,
147
+ analyticsWeekly: reportingAnalyticsWeekly,
55
148
  };
package/dist/types.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { RecentEvent, RecentOptions, SeriesOptions, SeriesPoint, WeeklyPoint } from './analytics/reader.js';
3
+ import type { Device } from './analytics/schema.js';
2
4
  import type { Actor, EventInput, FlatData, Level } from './schema.js';
3
5
  import type { ReportingEventRow, tables } from './tables.js';
4
6
  /**
@@ -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. */
@@ -79,7 +87,34 @@ export interface Stats {
79
87
  readonly dropped: number;
80
88
  readonly invalid: number;
81
89
  readonly flushed: number;
82
- readonly failedFlushes: number;
90
+ }
91
+ /** A server-side analytics event: the server's truth about something a person did, with identity from the `Access`, never a claim. */
92
+ export interface TrackInput {
93
+ readonly name: string;
94
+ readonly props?: Record<string, string | number | boolean | null>;
95
+ readonly tenantId?: string | null;
96
+ /** The signed-in person's id, from the host's resolved principal. */
97
+ readonly userId?: string | null;
98
+ /** The pathname the event belongs to; normalised before storage. Default `/`. */
99
+ readonly path?: string;
100
+ readonly device?: Device;
101
+ readonly country?: string | null;
102
+ /** The consent cookie's value, when the host passes it through. */
103
+ readonly visitorId?: string | null;
104
+ readonly sessionId?: string | null;
105
+ readonly occurredAt?: Date;
106
+ }
107
+ export interface Analytics {
108
+ /** Never throws in production: a failed insert is a log line. */
109
+ track(input: TrackInput): Promise<void>;
110
+ series(opts: SeriesOptions): Promise<readonly SeriesPoint[]>;
111
+ weekly(opts: {
112
+ site: string;
113
+ tenantId?: string;
114
+ from: string;
115
+ to: string;
116
+ }): Promise<readonly WeeklyPoint[]>;
117
+ recent(opts: RecentOptions): Promise<readonly RecentEvent[]>;
83
118
  }
84
119
  export interface Reporting {
85
120
  /** Never throws in production; see writer.ts. */
@@ -99,8 +134,8 @@ export interface Reporting {
99
134
  };
100
135
  readonly tables: typeof tables;
101
136
  readonly site: string;
102
- readonly db: Db;
103
137
  readonly log: Logger;
138
+ readonly analytics: Analytics;
104
139
  stats(): Stats;
105
140
  }
106
141
  export type { Actor, EventInput, FlatData, Level };
package/dist/writer.d.ts CHANGED
@@ -20,7 +20,6 @@ export declare class Writer {
20
20
  private droppedSinceReport;
21
21
  private invalid;
22
22
  private flushed;
23
- private failedFlushes;
24
23
  constructor(o: WriterOptions);
25
24
  event(input: EventInput): void;
26
25
  private toRow;
package/dist/writer.js CHANGED
@@ -10,7 +10,6 @@ export class Writer {
10
10
  droppedSinceReport = 0;
11
11
  invalid = 0;
12
12
  flushed = 0;
13
- failedFlushes = 0;
14
13
  constructor(o) {
15
14
  this.o = o;
16
15
  }
@@ -119,8 +118,7 @@ export class Writer {
119
118
  ok = await attempt;
120
119
  }
121
120
  finally {
122
- if (this.inFlight === attempt)
123
- this.inFlight = null;
121
+ this.inFlight = null;
124
122
  }
125
123
  // A failed insert leaves its rows queued; do not spin on the database
126
124
  // inside one call. The timer and the next defer try again.
@@ -152,7 +150,6 @@ export class Writer {
152
150
  return true;
153
151
  }
154
152
  catch (error) {
155
- this.failedFlushes += 1;
156
153
  // Back at the front, so order survives; the bound still applies, and
157
154
  // whatever does not fit is counted as dropped rather than kept for ever.
158
155
  const room = Math.max(0, this.o.queueLimit - this.queue.length);
@@ -170,7 +167,6 @@ export class Writer {
170
167
  dropped: this.dropped,
171
168
  invalid: this.invalid,
172
169
  flushed: this.flushed,
173
- failedFlushes: this.failedFlushes,
174
170
  };
175
171
  }
176
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/reporting",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -30,6 +30,18 @@
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
+ },
33
45
  "./migrations/*.sql": "./dist/migrations/*.sql",
34
46
  "./package.json": "./package.json"
35
47
  },
@@ -49,6 +61,7 @@
49
61
  "peerDependencies": {
50
62
  "drizzle-orm": ">=0.39.0",
51
63
  "next": ">=16.0.0",
64
+ "react": ">=19.0.0",
52
65
  "zod": "^4.0.0"
53
66
  },
54
67
  "peerDependenciesMeta": {
@@ -59,6 +72,7 @@
59
72
  "devDependencies": {
60
73
  "@electric-sql/pglite": "^0.5.8",
61
74
  "@types/node": "^22",
75
+ "@types/react": "^19",
62
76
  "drizzle-orm": "^0.39.3",
63
77
  "next": "^16.2.4",
64
78
  "postgres": "^3.4.5",