@wtfalch/reporting 0.1.0 → 0.1.2

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/alerts.js CHANGED
@@ -32,12 +32,8 @@ export function projectDetail(detail) {
32
32
  out[key] = Number.isFinite(value) ? value : String(value);
33
33
  else if (typeof value === 'string')
34
34
  out[key] = value.slice(0, 500);
35
- else if (Array.isArray(value))
36
- out[key] = `[${value.length} items]`;
37
35
  else
38
36
  out[key] = '[nested]';
39
- if (Object.keys(out).length >= 40)
40
- break;
41
37
  }
42
38
  return out;
43
39
  }
@@ -12,11 +12,5 @@ import { copyMigrations, describeCopy } from './copy.js';
12
12
  const here = dirname(fileURLToPath(import.meta.url));
13
13
  const from = join(here, '..', 'migrations');
14
14
  const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
15
- let version;
16
- try {
17
- version = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8')).version;
18
- }
19
- catch {
20
- version = undefined;
21
- }
15
+ const { version } = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
22
16
  console.log(describeCopy(copyMigrations({ from, to, version })));
@@ -16,11 +16,6 @@ export function createFakeReporting(opts = {}) {
16
16
  tables,
17
17
  rows,
18
18
  alerts,
19
- // Not a database; a host's test that reaches `db` through the fake is
20
- // testing the wrong thing, and this makes that loud.
21
- get db() {
22
- throw new Error('createFakeReporting has no database');
23
- },
24
19
  event(input) {
25
20
  const v = eventInputSchema.parse(input);
26
21
  id += 1;
@@ -93,7 +88,7 @@ export function createFakeReporting(opts = {}) {
93
88
  return { before, after: settings, by, changed };
94
89
  },
95
90
  },
96
- stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length, failedFlushes: 0 }),
91
+ stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length }),
97
92
  reset() {
98
93
  rows.length = 0;
99
94
  alerts.length = 0;
@@ -43,12 +43,10 @@ export interface TaskContext {
43
43
  }
44
44
  export interface HousekeepingOptions {
45
45
  reporting: Reporting;
46
- db?: Db;
46
+ db: Db;
47
47
  now?: () => Date;
48
48
  /** Per process. Default 60,000 ms. */
49
49
  debounceMs?: number;
50
- /** Tasks run at once. Default 2. */
51
- concurrency?: number;
52
50
  }
53
51
  export interface Housekeeping {
54
52
  /** Idempotent by name. The row is created on the next tick. */
@@ -2,12 +2,13 @@ import { eq, sql } from 'drizzle-orm';
2
2
  import { KIND_PATTERN } from '../schema.js';
3
3
  import { reportingTasks } from '../tables.js';
4
4
  import { describe } from '../writer.js';
5
+ /** Tasks run at once: enough that a slow prune does not delay the alerts, few enough to be one connection each. */
6
+ const CONCURRENCY = 2;
5
7
  export function createHousekeeping(options) {
6
8
  const { reporting } = options;
7
- const db = options.db ?? reporting.db;
9
+ const db = options.db;
8
10
  const now = options.now ?? (() => new Date());
9
11
  const debounceMs = options.debounceMs ?? 60_000;
10
- const concurrency = Math.max(1, options.concurrency ?? 2);
11
12
  const registry = new Map();
12
13
  let lastTick = 0;
13
14
  let ticking = null;
@@ -132,7 +133,7 @@ export function createHousekeeping(options) {
132
133
  const settings = await reporting.settings.get();
133
134
  const queue = [...registry.values()];
134
135
  const counts = { claimed: 0, skipped: 0, ran: 0, failed: 0 };
135
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
136
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
136
137
  for (let task = queue.shift(); task; task = queue.shift()) {
137
138
  try {
138
139
  const outcome = await runOne(task, settings);
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export * from './schema.js';
3
3
  export * from './tables.js';
4
4
  export * from './types.js';
5
5
  export { alertKind, projectDetail } from './alerts.js';
6
- export { DEFAULT_SETTINGS, RETENTION_BOUNDS, SETTING_KEYS } from './settings.js';
6
+ export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
7
7
  /**
8
8
  * The host's one instance, built once over its drizzle handle and its
9
9
  * logger. Framework-neutral: `defer` is how the host says "after this unit of
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export * from './schema.js';
8
8
  export * from './tables.js';
9
9
  export * from './types.js';
10
10
  export { alertKind, projectDetail } from './alerts.js';
11
- export { DEFAULT_SETTINGS, RETENTION_BOUNDS, SETTING_KEYS } from './settings.js';
11
+ export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
12
12
  function modeFromEnv() {
13
13
  const env = process.env.NODE_ENV;
14
14
  return env === 'development' || env === 'test' ? env : 'production';
@@ -39,7 +39,6 @@ export function createReporting(options) {
39
39
  });
40
40
  const reporting = {
41
41
  site,
42
- db: options.db,
43
42
  log: options.log,
44
43
  tables,
45
44
  event: (input) => writer.event(input),
@@ -48,7 +48,9 @@ create table if not exists reporting_events (
48
48
  -- provider response or a request body sneaks in whole. `strict`, because
49
49
  -- lax mode unwraps an array before the filter sees it and would never
50
50
  -- call one an array.
51
- constraint reporting_events_data_flat_check check (not jsonb_path_exists(data, 'strict $.* ? (@.type() == "object" || @.type() == "array")')),
51
+ -- `silent`, so a non-object `data` fails the object check by name rather
52
+ -- than raising 22033 from this one, whichever the planner evaluates first.
53
+ constraint reporting_events_data_flat_check check (not jsonb_path_exists(data, 'strict $.* ? (@.type() == "object" || @.type() == "array")', '{}', true)),
52
54
  -- The keys that carry a person or a secret by name. A guardrail, not a
53
55
  -- proof: src/schema.ts refuses the same list before a row is queued, and
54
56
  -- schema.test.ts pins the two lists equal.
@@ -86,9 +88,6 @@ create table if not exists reporting_tasks (
86
88
  claim_token uuid,
87
89
  last_outcome text,
88
90
  last_error text,
89
- -- Free for a task's own bookkeeping: the analytics rollup keeps its
90
- -- completed-day watermark here.
91
- watermark text,
92
91
  constraint reporting_tasks_task_check check (task ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
93
92
  constraint reporting_tasks_outcome_check check (last_outcome is null or last_outcome in ('ok', 'error', 'skipped')),
94
93
  constraint reporting_tasks_error_check check (last_error is null or length(last_error) <= 512)
@@ -19,6 +19,8 @@ export function healthHandler(housekeeping) {
19
19
  return Response.json({ ok: true, ts: new Date().toISOString() });
20
20
  };
21
21
  }
22
+ /** `<16 hex>-<colo>`, as Cloudflare emits it. */
23
+ const RAY_PATTERN = /^[0-9a-f]{16}-[A-Z0-9]{3,6}$/;
22
24
  /**
23
25
  * Read once where a request enters (a Server Action, a route handler, a
24
26
  * page's data component) and handed down. Outside a request scope it returns
@@ -30,8 +32,12 @@ export async function requestContext() {
30
32
  try {
31
33
  const h = await headers();
32
34
  const country = h.get('cf-ipcountry');
35
+ // A ray id is Cloudflare's or it is nothing: a request that reached the
36
+ // origin some other way (the Docker network, a preview host) could
37
+ // otherwise choose the id stamped on the audit rows of this request.
38
+ const ray = h.get('cf-ray');
33
39
  return {
34
- id: (h.get('cf-ray') ?? randomUUID()).slice(0, 128),
40
+ id: ray && RAY_PATTERN.test(ray) ? ray : randomUUID(),
35
41
  ip: h.get('cf-connecting-ip'),
36
42
  userAgent: h.get('user-agent'),
37
43
  country: country && /^[A-Z]{2}$/.test(country) ? country : null,
package/dist/schema.d.ts CHANGED
@@ -28,9 +28,16 @@ export declare const LIMITS: {
28
28
  readonly dataBytes: 16384;
29
29
  readonly site: 64;
30
30
  };
31
- /** Flat, bounded, and free of the banned keys. Numbers must be finite: JSON has no NaN. */
31
+ /** Flat, bounded, banned keys refused, numbers finite. */
32
32
  export declare const flatDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
33
33
  export type FlatData = z.infer<typeof flatDataSchema>;
34
+ /**
35
+ * `z.object`, not strict: a host hands over its resolved principal, which
36
+ * carries a display name and an address beside `class` and `id`, and those
37
+ * must be dropped rather than refused. Stripping is the privacy behaviour;
38
+ * refusing would make every host's call site narrow by hand, and the first
39
+ * one did not.
40
+ */
34
41
  export declare const actorSchema: z.ZodObject<{
35
42
  class: z.ZodEnum<{
36
43
  human: "human";
@@ -39,12 +46,12 @@ export declare const actorSchema: z.ZodObject<{
39
46
  service: "service";
40
47
  }>;
41
48
  id: z.ZodString;
42
- }, z.core.$strict>;
49
+ }, z.core.$strip>;
43
50
  export type Actor = z.infer<typeof actorSchema>;
44
51
  export declare const targetSchema: z.ZodObject<{
45
52
  type: z.ZodString;
46
53
  id: z.ZodString;
47
- }, z.core.$strict>;
54
+ }, z.core.$strip>;
48
55
  /** What a caller hands `event()`. Everything else on the row is the writer's. */
49
56
  export declare const eventInputSchema: z.ZodObject<{
50
57
  kind: z.ZodString;
@@ -64,12 +71,12 @@ export declare const eventInputSchema: z.ZodObject<{
64
71
  service: "service";
65
72
  }>;
66
73
  id: z.ZodString;
67
- }, z.core.$strict>>>;
74
+ }, z.core.$strip>>>;
68
75
  requestId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
76
  target: z.ZodOptional<z.ZodNullable<z.ZodObject<{
70
77
  type: z.ZodString;
71
78
  id: z.ZodString;
72
- }, z.core.$strict>>>;
79
+ }, z.core.$strip>>>;
73
80
  data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
74
81
  occurredAt: z.ZodOptional<z.ZodDate>;
75
82
  }, z.core.$strict>;
package/dist/schema.js CHANGED
@@ -36,7 +36,7 @@ export const LIMITS = {
36
36
  site: 64,
37
37
  };
38
38
  const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
39
- /** Flat, bounded, and free of the banned keys. Numbers must be finite: JSON has no NaN. */
39
+ /** Flat, bounded, banned keys refused, numbers finite. */
40
40
  export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ctx) => {
41
41
  for (const key of Object.keys(data)) {
42
42
  if (BANNED_KEYS.includes(key)) {
@@ -55,11 +55,18 @@ export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ct
55
55
  }
56
56
  });
57
57
  const bounded = (max) => z.string().min(1).max(max);
58
- export const actorSchema = z.strictObject({
58
+ /**
59
+ * `z.object`, not strict: a host hands over its resolved principal, which
60
+ * carries a display name and an address beside `class` and `id`, and those
61
+ * must be dropped rather than refused. Stripping is the privacy behaviour;
62
+ * refusing would make every host's call site narrow by hand, and the first
63
+ * one did not.
64
+ */
65
+ export const actorSchema = z.object({
59
66
  class: z.enum(ACTOR_CLASSES),
60
67
  id: bounded(LIMITS.actorId),
61
68
  });
62
- export const targetSchema = z.strictObject({
69
+ export const targetSchema = z.object({
63
70
  type: bounded(LIMITS.targetType),
64
71
  id: bounded(LIMITS.targetId),
65
72
  });
@@ -9,7 +9,6 @@ export declare const RETENTION_BOUNDS: {
9
9
  readonly min: 7;
10
10
  readonly max: 400;
11
11
  };
12
- export declare const SETTING_KEYS: (keyof Settings)[];
13
12
  export declare const DEFAULT_SETTINGS: Settings;
14
13
  export declare function getSettings(db: Db): Promise<Settings>;
15
14
  /** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
package/dist/settings.js CHANGED
@@ -14,7 +14,6 @@ const DEFS = {
14
14
  default: 30,
15
15
  },
16
16
  };
17
- export const SETTING_KEYS = Object.keys(DEFS);
18
17
  export const DEFAULT_SETTINGS = Object.freeze({
19
18
  'events.retention_days': DEFS['events.retention_days'].default,
20
19
  });
package/dist/tables.d.ts CHANGED
@@ -240,7 +240,7 @@ export declare const reportingEvents: import("drizzle-orm/pg-core").PgTableWithC
240
240
  data: Record<string, string | number | boolean | null>;
241
241
  driverParam: unknown;
242
242
  notNull: true;
243
- hasDefault: false;
243
+ hasDefault: true;
244
244
  isPrimaryKey: false;
245
245
  isAutoincrement: false;
246
246
  hasRuntimeDefault: false;
@@ -411,23 +411,6 @@ export declare const reportingTasks: import("drizzle-orm/pg-core").PgTableWithCo
411
411
  identity: undefined;
412
412
  generated: undefined;
413
413
  }, {}, {}>;
414
- watermark: import("drizzle-orm/pg-core").PgColumn<{
415
- name: "watermark";
416
- tableName: "reporting_tasks";
417
- dataType: "string";
418
- columnType: "PgText";
419
- data: string;
420
- driverParam: string;
421
- notNull: false;
422
- hasDefault: false;
423
- isPrimaryKey: false;
424
- isAutoincrement: false;
425
- hasRuntimeDefault: false;
426
- enumValues: [string, ...string[]];
427
- baseColumn: never;
428
- identity: undefined;
429
- generated: undefined;
430
- }, {}, {}>;
431
414
  };
432
415
  dialect: "pg";
433
416
  }>;
@@ -745,7 +728,7 @@ export declare const tables: {
745
728
  data: Record<string, string | number | boolean | null>;
746
729
  driverParam: unknown;
747
730
  notNull: true;
748
- hasDefault: false;
731
+ hasDefault: true;
749
732
  isPrimaryKey: false;
750
733
  isAutoincrement: false;
751
734
  hasRuntimeDefault: false;
@@ -916,23 +899,6 @@ export declare const tables: {
916
899
  identity: undefined;
917
900
  generated: undefined;
918
901
  }, {}, {}>;
919
- watermark: import("drizzle-orm/pg-core").PgColumn<{
920
- name: "watermark";
921
- tableName: "reporting_tasks";
922
- dataType: "string";
923
- columnType: "PgText";
924
- data: string;
925
- driverParam: string;
926
- notNull: false;
927
- hasDefault: false;
928
- isPrimaryKey: false;
929
- isAutoincrement: false;
930
- hasRuntimeDefault: false;
931
- enumValues: [string, ...string[]];
932
- baseColumn: never;
933
- identity: undefined;
934
- generated: undefined;
935
- }, {}, {}>;
936
902
  };
937
903
  dialect: "pg";
938
904
  }>;
package/dist/tables.js CHANGED
@@ -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,7 +48,6 @@ export const reportingTasks = pgTable('reporting_tasks', {
40
48
  claimToken: uuid('claim_token'),
41
49
  lastOutcome: text('last_outcome'),
42
50
  lastError: text('last_error'),
43
- watermark: text('watermark'),
44
51
  });
45
52
  export const reportingSettings = pgTable('reporting_settings', {
46
53
  key: text('key').primaryKey(),
package/dist/types.d.ts CHANGED
@@ -79,7 +79,6 @@ export interface Stats {
79
79
  readonly dropped: number;
80
80
  readonly invalid: number;
81
81
  readonly flushed: number;
82
- readonly failedFlushes: number;
83
82
  }
84
83
  export interface Reporting {
85
84
  /** Never throws in production; see writer.ts. */
@@ -99,7 +98,6 @@ export interface Reporting {
99
98
  };
100
99
  readonly tables: typeof tables;
101
100
  readonly site: string;
102
- readonly db: Db;
103
101
  readonly log: Logger;
104
102
  stats(): Stats;
105
103
  }
package/dist/writer.d.ts CHANGED
@@ -20,18 +20,24 @@ 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;
27
26
  private enqueue;
28
27
  /** Once per idle period through `defer`, and always on the timer. */
29
28
  private schedule;
30
- /** Drain until empty or the deadline. One flush in flight at a time; a second call waits on the first. */
29
+ /**
30
+ * Drain until the queue is empty and nothing is in flight, or the deadline.
31
+ * Two callers overlap all the time (the timer and a request's `after()`,
32
+ * the tick and its own timer), so "empty" is not enough: a row another
33
+ * flush spliced off the queue a moment ago is still on its way, and a
34
+ * caller that returned on an empty queue would read the table too early.
35
+ * Every caller therefore waits for whatever is in flight and looks again.
36
+ */
31
37
  flush(opts?: {
32
38
  deadlineMs?: number;
33
39
  }): Promise<void>;
34
- private lastFailed;
40
+ /** One insert of one batch. Resolves true on success; never throws. */
35
41
  private flushOnce;
36
42
  stats(): Stats;
37
43
  }
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
  }
@@ -95,25 +94,40 @@ export class Writer {
95
94
  this.timer.unref?.();
96
95
  }
97
96
  }
98
- /** Drain until empty or the deadline. One flush in flight at a time; a second call waits on the first. */
97
+ /**
98
+ * Drain until the queue is empty and nothing is in flight, or the deadline.
99
+ * Two callers overlap all the time (the timer and a request's `after()`,
100
+ * the tick and its own timer), so "empty" is not enough: a row another
101
+ * flush spliced off the queue a moment ago is still on its way, and a
102
+ * caller that returned on an empty queue would read the table too early.
103
+ * Every caller therefore waits for whatever is in flight and looks again.
104
+ */
99
105
  async flush(opts = {}) {
100
106
  const deadline = Date.now() + (opts.deadlineMs ?? 5000);
101
- if (this.inFlight)
102
- await this.inFlight;
103
- while (this.queue.length > 0 && Date.now() < deadline) {
104
- this.inFlight = this.flushOnce();
105
- try {
107
+ while (Date.now() < deadline) {
108
+ if (this.inFlight) {
106
109
  await this.inFlight;
110
+ continue;
111
+ }
112
+ if (this.queue.length === 0)
113
+ break;
114
+ const attempt = this.flushOnce();
115
+ this.inFlight = attempt;
116
+ let ok = false;
117
+ try {
118
+ ok = await attempt;
107
119
  }
108
120
  finally {
109
121
  this.inFlight = null;
110
122
  }
111
- if (this.failedFlushes > 0 && this.queue.length > 0 && this.lastFailed)
123
+ // A failed insert leaves its rows queued; do not spin on the database
124
+ // inside one call. The timer and the next defer try again.
125
+ if (!ok)
112
126
  break;
113
127
  }
114
128
  this.deferred = false;
115
129
  }
116
- lastFailed = false;
130
+ /** One insert of one batch. Resolves true on success; never throws. */
117
131
  async flushOnce() {
118
132
  const batch = this.queue.splice(0, this.o.batchSize);
119
133
  if (this.droppedSinceReport > 0) {
@@ -133,11 +147,9 @@ export class Writer {
133
147
  try {
134
148
  await this.o.db.insert(reportingEvents).values(batch);
135
149
  this.flushed += batch.length;
136
- this.lastFailed = false;
150
+ return true;
137
151
  }
138
152
  catch (error) {
139
- this.failedFlushes += 1;
140
- this.lastFailed = true;
141
153
  // Back at the front, so order survives; the bound still applies, and
142
154
  // whatever does not fit is counted as dropped rather than kept for ever.
143
155
  const room = Math.max(0, this.o.queueLimit - this.queue.length);
@@ -146,6 +158,7 @@ export class Writer {
146
158
  this.droppedSinceReport += batch.length - kept.length;
147
159
  this.queue.unshift(...kept);
148
160
  this.o.log.error({ err: describe(error), rows: batch.length, queued: this.queue.length }, 'reporting: flush failed; rows stay queued');
161
+ return false;
149
162
  }
150
163
  }
151
164
  stats() {
@@ -154,7 +167,6 @@ export class Writer {
154
167
  dropped: this.dropped,
155
168
  invalid: this.invalid,
156
169
  flushed: this.flushed,
157
- failedFlushes: this.failedFlushes,
158
170
  };
159
171
  }
160
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/reporting",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",