@wtfalch/reporting 0.7.0 → 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 CHANGED
@@ -64,6 +64,7 @@ await ensureRuntimeRole({
64
64
  'reporting_errors',
65
65
  'reporting_tenant_settings',
66
66
  'reporting_error_tenants',
67
+ 'reporting_requests',
67
68
  ],
68
69
  grants: [ // the seven security definer functions, schema-qualified
69
70
  'myservice.reporting_prune_events(interval, integer)',
@@ -79,7 +80,7 @@ await ensureRuntimeRole({
79
80
 
80
81
  Run `ensureRuntimeRole` after every migration run: the lists apply to the
81
82
  tables that exist at that moment. `grants` entries are spliced into SQL, so
82
- they are trusted text. Without `noDelete` the role can delete from those five
83
+ they are trusted text. Without `noDelete` the role can delete from those six
83
84
  tables. These are the privileges the
84
85
  package's own tests check, on a real Postgres, in a named schema.
85
86
 
@@ -98,6 +99,7 @@ await assertRuntimeRole(connection.database, {
98
99
  'myservice.reporting_errors',
99
100
  'myservice.reporting_tenant_settings',
100
101
  'myservice.reporting_error_tenants',
102
+ 'myservice.reporting_requests',
101
103
  ],
102
104
  });
103
105
  ```
@@ -205,6 +207,31 @@ edit can shorten history to a week and no further. Pass `audit` to
205
207
  `createReporting` to have every change handed to your audit ledger after it
206
208
  commits.
207
209
 
210
+ ## Release health (0.8.0)
211
+
212
+ With `REPORTING_RELEASE` (an image digest) and `REPORTING_COLOR` (`blue` or
213
+ `green`) set, every event, analytics and error row carries both. Count each
214
+ response and mount the read foundry judges a new color by:
215
+
216
+ ```ts
217
+ // app/api/reporting/release-health/route.ts
218
+ import { releaseHealthHandler } from '@wtfalch/reporting/next';
219
+ export const GET = releaseHealthHandler({ db, reporting });
220
+
221
+ // any route handler: counted per minute, color and release (status >= 500 is an error)
222
+ import { withRequestCount } from '@wtfalch/reporting/next';
223
+ export const POST = withRequestCount(reporting, async (req) => Response.json({ ok: true }));
224
+ ```
225
+
226
+ `reporting.requests.record(status)` is the same count for a host that has no
227
+ route handler to wrap. Counts sit in memory and are written once per minute
228
+ bucket (every 10 seconds by default; `reporting.flush()` writes them now). The
229
+ read needs `Authorization: Bearer $REPORTING_HEALTH_TOKEN`: 401 for a missing or
230
+ wrong token, 503 when the variable is unset. It answers `from` and `to` (ISO,
231
+ `from` before `to`, at most 8 days; otherwise 400) with request and 5xx counts
232
+ per color and the per-day average of the 7 days before `from`. Counts only; no
233
+ event content. Rows of `reporting_requests` are not pruned.
234
+
208
235
  ## Shutdown
209
236
 
210
237
  ```ts
@@ -291,11 +318,10 @@ the rollups hold nothing to erase. Call it in the host's erasure transaction.
291
318
 
292
319
  ## Tests
293
320
 
294
- ```ts
295
- import { createFakeReporting } from '@wtfalch/reporting/fake';
296
- ```
297
-
298
- Same validation, no database; `fake.rows` is the table.
321
+ No package ships a stand-in for a service. `createFakeReporting` (same
322
+ validation, no database, `fake.rows` is the table) is this package's own
323
+ unexported test double, under `src/test/`, for its own test files only -- a
324
+ host writes its own double, or runs this package against a real database.
299
325
 
300
326
  ## What this package never does
301
327
 
@@ -1,7 +1,9 @@
1
+ import { deployTags } from '../deploy.js';
1
2
  import { reportingAnalytics } from '../tables.js';
2
3
  export async function insertAnalytics(db, rows) {
3
4
  if (rows.length === 0)
4
5
  return;
6
+ const tags = deployTags();
5
7
  await db.insert(reportingAnalytics).values(rows.map((r) => ({
6
8
  occurredAt: r.occurredAt,
7
9
  receivedAt: r.receivedAt,
@@ -16,5 +18,6 @@ export async function insertAnalytics(db, rows) {
16
18
  device: r.device,
17
19
  country: r.country,
18
20
  props: r.props,
21
+ ...tags,
19
22
  })));
20
23
  }
@@ -0,0 +1,11 @@
1
+ export type Color = 'blue' | 'green';
2
+ export interface DeployTags {
3
+ readonly release: string | null;
4
+ readonly color: Color | null;
5
+ }
6
+ /**
7
+ * The release and color the platform gave this container
8
+ * (`REPORTING_RELEASE`, `REPORTING_COLOR`). A color that is not blue or green
9
+ * is null, so a bad value cannot fail a write on the column's CHECK.
10
+ */
11
+ export declare function deployTags(env?: NodeJS.ProcessEnv): DeployTags;
package/dist/deploy.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The release and color the platform gave this container
3
+ * (`REPORTING_RELEASE`, `REPORTING_COLOR`). A color that is not blue or green
4
+ * is null, so a bad value cannot fail a write on the column's CHECK.
5
+ */
6
+ export function deployTags(env = process.env) {
7
+ const color = env.REPORTING_COLOR;
8
+ const release = env.REPORTING_RELEASE?.slice(0, 128);
9
+ return {
10
+ release: release ? release : null,
11
+ color: color === 'blue' || color === 'green' ? color : null,
12
+ };
13
+ }
@@ -1,4 +1,5 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
+ import { deployTags } from '../deploy.js';
2
3
  import { ERROR_RUNTIMES, KIND_PATTERN, LIMITS } from '../schema.js';
3
4
  import { reportingErrorTenants, reportingErrors } from '../tables.js';
4
5
  import { describe } from '../writer.js';
@@ -122,6 +123,7 @@ async function upsertGroup(db, site, s, log) {
122
123
  stack: s.stack,
123
124
  runtime: s.runtime,
124
125
  release: s.release,
126
+ color: s.color,
125
127
  environment: s.environment,
126
128
  firstSeenAt: s.at,
127
129
  lastSeenAt: s.at,
@@ -139,6 +141,7 @@ async function upsertGroup(db, site, s, log) {
139
141
  tenantId: s.tenantId,
140
142
  requestId: s.requestId,
141
143
  release: s.release,
144
+ color: s.color,
142
145
  environment: s.environment,
143
146
  state: reopened,
144
147
  resolvedAt: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedAt} end`,
@@ -237,6 +240,7 @@ export function createCapture(o) {
237
240
  stack,
238
241
  runtime,
239
242
  release,
243
+ color: deployTags().color,
240
244
  environment,
241
245
  tenantId,
242
246
  requestId,
@@ -35,6 +35,7 @@ function tenantColumns() {
35
35
  stack: sql `null`.as('stack'),
36
36
  runtime: sql `null`.as('runtime'),
37
37
  release: sql `null`.as('release'),
38
+ color: sql `null`.as('color'),
38
39
  requestId: sql `null`.as('request_id'),
39
40
  resolvedBy: sql `null`.as('resolved_by'),
40
41
  state: sql `null`.as('state'),
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { ANALYTICS_NAME_PATTERN, normalisePath, propsSchema } from './analytics/
4
4
  import { insertAnalytics } from './analytics/write.js';
5
5
  import { createCapture } from './errors/capture.js';
6
6
  import { eventsPage } from './reader.js';
7
+ import { RequestCounter } from './requests.js';
7
8
  import { environmentSchema, siteSchema } from './schema.js';
8
9
  import { getSettings, getTenantRetention, setSettings, setTenantRetention } from './settings.js';
9
10
  import { tables } from './tables.js';
@@ -54,6 +55,13 @@ export function createReporting(options) {
54
55
  batchSize: options.batchSize ?? 500,
55
56
  flushEveryMs: options.flushEveryMs ?? 1000,
56
57
  });
58
+ const requests = new RequestCounter({
59
+ db: options.db,
60
+ log: options.log,
61
+ site,
62
+ now,
63
+ flushEveryMs: options.requestFlushEveryMs ?? 10_000,
64
+ });
57
65
  // One alerting path, shared by `reporting.alert()` and a new-or-reopened
58
66
  // capture (errors/capture.ts's `alert` option): an `alert.<check>` row,
59
67
  // already indexed and pruned the same as everything else on the
@@ -91,7 +99,11 @@ export function createReporting(options) {
91
99
  event: (input) => writer.event(input),
92
100
  captureError: (error, context) => capture(error, context),
93
101
  alert: fireAlert,
94
- flush: (opts) => writer.flush(opts),
102
+ flush: async (opts) => {
103
+ await writer.flush(opts);
104
+ await requests.flush();
105
+ },
106
+ requests: { record: (status) => requests.record(status) },
95
107
  events: { page: (opts) => eventsPage(options.db, opts) },
96
108
  settings: {
97
109
  get: () => getSettings(options.db),
@@ -0,0 +1,33 @@
1
+ -- @wtfalch/reporting 0.8.0: the release and color a row was written under.
2
+ -- Mirrors src/tables.ts.
3
+ --
4
+ -- A blue/green deploy runs two copies of an app against one database. The
5
+ -- host gets REPORTING_RELEASE (an image digest) and REPORTING_COLOR (blue or
6
+ -- green) from its platform, and the package stamps both on every row it
7
+ -- writes. Both are nullable: rows written before this file, and rows from an
8
+ -- app with no color, carry null. reporting_errors already has `release`.
9
+ --
10
+ -- Same shape as 0004_environment.sql: `add column if not exists` takes only a
11
+ -- catalog lock, and the CHECK goes in `not valid` then `validate constraint`
12
+ -- so the scan of a large table does not hold ACCESS EXCLUSIVE. Every
13
+ -- statement is idempotent; no backfill.
14
+
15
+ alter table reporting_events add column if not exists release text;
16
+ alter table reporting_events add column if not exists color text;
17
+ alter table reporting_events drop constraint if exists reporting_events_color_check;
18
+ alter table reporting_events add constraint reporting_events_color_check
19
+ check (color is null or color in ('blue', 'green')) not valid;
20
+ alter table reporting_events validate constraint reporting_events_color_check;
21
+
22
+ alter table reporting_analytics add column if not exists release text;
23
+ alter table reporting_analytics add column if not exists color text;
24
+ alter table reporting_analytics drop constraint if exists reporting_analytics_color_check;
25
+ alter table reporting_analytics add constraint reporting_analytics_color_check
26
+ check (color is null or color in ('blue', 'green')) not valid;
27
+ alter table reporting_analytics validate constraint reporting_analytics_color_check;
28
+
29
+ alter table reporting_errors add column if not exists color text;
30
+ alter table reporting_errors drop constraint if exists reporting_errors_color_check;
31
+ alter table reporting_errors add constraint reporting_errors_color_check
32
+ check (color is null or color in ('blue', 'green')) not valid;
33
+ alter table reporting_errors validate constraint reporting_errors_color_check;
@@ -0,0 +1,27 @@
1
+ -- @wtfalch/reporting 0.8.0: a request counter, one row per minute.
2
+ -- Mirrors src/tables.ts.
3
+ --
4
+ -- Blue/green judges a new color by its share of failed responses, which needs
5
+ -- a count of all requests, not one event per request. The app counts in
6
+ -- memory and writes one upsert per minute bucket, color and release.
7
+ -- `errors` is responses with status >= 500. `color` and `release` are the
8
+ -- empty string, not null, when the app has none, so they can sit in the
9
+ -- primary key. Counts only; nothing here identifies a person or a request.
10
+ -- Every statement is idempotent.
11
+ --
12
+ -- The table is new, so there are no live rows to scan and the CHECKs go in
13
+ -- the plain way. Select, insert and update for the runtime role; nothing that
14
+ -- deletes (the host's ensureRuntimeRole call sets that; see the README).
15
+
16
+ create table if not exists reporting_requests (
17
+ bucket timestamptz not null,
18
+ site text not null,
19
+ color text not null default '',
20
+ release text not null default '',
21
+ requests bigint not null default 0,
22
+ errors bigint not null default 0,
23
+ constraint reporting_requests_pkey primary key (bucket, site, color, release),
24
+ constraint reporting_requests_site_check check (site ~ '^[a-z][a-z0-9-]{0,63}$'),
25
+ constraint reporting_requests_color_check check (color in ('', 'blue', 'green')),
26
+ constraint reporting_requests_counts_check check (requests >= 0 and errors between 0 and requests)
27
+ );
@@ -3,6 +3,7 @@ import { deviceOf } from '../analytics/schema.js';
3
3
  import type { Reporting } from '../types.js';
4
4
  import { type ClientErrorCollectorOptions } from './client-errors.js';
5
5
  import { type EdgeErrorIngestOptions } from './edge-errors.js';
6
+ export { releaseHealthHandler, withRequestCount, type ReleaseHealthOptions, } from './release-health.js';
6
7
  export { edgeErrorHandler, type EdgeCaptureOptions, type EdgeErrorReport } from './edge-errors.js';
7
8
  /**
8
9
  * The Next bindings: everything in the package that knows what a request is.
@@ -6,6 +6,7 @@ import { createCollector } from '../analytics/collector.js';
6
6
  import { countryOf, deviceOf } from '../analytics/schema.js';
7
7
  import { createClientErrorCollector } from './client-errors.js';
8
8
  import { createEdgeErrorIngestCollector } from './edge-errors.js';
9
+ export { releaseHealthHandler, withRequestCount, } from './release-health.js';
9
10
  export { edgeErrorHandler } from './edge-errors.js';
10
11
  /**
11
12
  * The Next bindings: everything in the package that knows what a request is.
@@ -0,0 +1,22 @@
1
+ import type { Db, Reporting } from '../types.js';
2
+ export interface ReleaseHealthOptions {
3
+ readonly db: Db;
4
+ readonly reporting: Pick<Reporting, 'site' | 'log'>;
5
+ /** Defaults to `process.env.REPORTING_HEALTH_TOKEN`, read on each request. */
6
+ readonly token?: () => string | undefined;
7
+ }
8
+ /**
9
+ * `GET /api/reporting/release-health?from=<ISO>&to=<ISO>`: the counts foundry
10
+ * judges a new color by. Request and 5xx totals per color over [from, to),
11
+ * and the per-day average of the seven days before `from`. Counts only; no
12
+ * event, error or request content is read. Bearer token from
13
+ * `REPORTING_HEALTH_TOKEN`: unset is 503, missing or wrong is 401.
14
+ */
15
+ export declare function releaseHealthHandler(options: ReleaseHealthOptions): (request: Request) => Promise<Response>;
16
+ /**
17
+ * Wraps a route handler so each response is counted. A handler that throws is
18
+ * counted as a 500 and the throw goes on. Next's own control-flow throws
19
+ * (`redirect()`, `notFound()` and the like) are counted as a request, not an
20
+ * error, so they cannot push a release toward rollback.
21
+ */
22
+ export declare function withRequestCount<A extends unknown[]>(reporting: Pick<Reporting, 'requests'>, handler: (...args: A) => Promise<Response> | Response): (...args: A) => Promise<Response>;
@@ -0,0 +1,122 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { and, gte, lt, sql } from 'drizzle-orm';
3
+ import { unstable_rethrow } from 'next/navigation';
4
+ import { connection } from 'next/server';
5
+ import { z } from 'zod';
6
+ import { reportingRequests } from '../tables.js';
7
+ const DAY_MS = 86_400_000;
8
+ const BASELINE_DAYS = 7;
9
+ const MAX_RANGE_MS = 8 * DAY_MS;
10
+ const iso = z.iso.datetime({ offset: true });
11
+ function tokenMatches(provided, expected) {
12
+ const a = Buffer.from(provided);
13
+ const b = Buffer.from(expected);
14
+ return a.length === b.length && timingSafeEqual(a, b);
15
+ }
16
+ function json(body, status) {
17
+ return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
18
+ }
19
+ /**
20
+ * `GET /api/reporting/release-health?from=<ISO>&to=<ISO>`: the counts foundry
21
+ * judges a new color by. Request and 5xx totals per color over [from, to),
22
+ * and the per-day average of the seven days before `from`. Counts only; no
23
+ * event, error or request content is read. Bearer token from
24
+ * `REPORTING_HEALTH_TOKEN`: unset is 503, missing or wrong is 401.
25
+ */
26
+ export function releaseHealthHandler(options) {
27
+ const token = options.token ?? (() => process.env.REPORTING_HEALTH_TOKEN);
28
+ const site = options.reporting.site;
29
+ return async (request) => {
30
+ await connection();
31
+ const expected = token();
32
+ if (!expected)
33
+ return json({ error: 'not configured' }, 503);
34
+ const header = request.headers.get('authorization') ?? '';
35
+ const match = /^Bearer (.+)$/.exec(header);
36
+ if (!match?.[1] || !tokenMatches(match[1], expected)) {
37
+ return json({ error: 'unauthorized' }, 401);
38
+ }
39
+ const params = new URL(request.url).searchParams;
40
+ const fromRaw = iso.safeParse(params.get('from'));
41
+ const toRaw = iso.safeParse(params.get('to'));
42
+ if (!fromRaw.success || !toRaw.success) {
43
+ return json({ error: 'from and to must be ISO timestamps' }, 400);
44
+ }
45
+ const from = new Date(fromRaw.data);
46
+ const to = new Date(toRaw.data);
47
+ if (!(from.getTime() < to.getTime()) || to.getTime() - from.getTime() > MAX_RANGE_MS) {
48
+ return json({ error: 'from must be before to, at most 8 days apart' }, 400);
49
+ }
50
+ try {
51
+ const sums = {
52
+ requests: sql `coalesce(sum(${reportingRequests.requests}), 0)`,
53
+ errors: sql `coalesce(sum(${reportingRequests.errors}), 0)`,
54
+ };
55
+ const inSite = sql `${reportingRequests.site} = ${site}`;
56
+ const byColor = await options.db
57
+ .select({ color: reportingRequests.color, ...sums })
58
+ .from(reportingRequests)
59
+ .where(and(inSite, gte(reportingRequests.bucket, from), lt(reportingRequests.bucket, to)))
60
+ .groupBy(reportingRequests.color);
61
+ const baselineFrom = new Date(from.getTime() - BASELINE_DAYS * DAY_MS);
62
+ const [baseline] = await options.db
63
+ .select(sums)
64
+ .from(reportingRequests)
65
+ .where(and(inSite, gte(reportingRequests.bucket, baselineFrom), lt(reportingRequests.bucket, from)));
66
+ const colors = {
67
+ blue: { requests: 0, errors: 0 },
68
+ green: { requests: 0, errors: 0 },
69
+ };
70
+ for (const row of byColor) {
71
+ if (row.color === 'blue' || row.color === 'green') {
72
+ colors[row.color] = { requests: Number(row.requests), errors: Number(row.errors) };
73
+ }
74
+ }
75
+ return json({
76
+ colors,
77
+ daily: {
78
+ days: BASELINE_DAYS,
79
+ requestsPerDay: Number(baseline?.requests ?? 0) / BASELINE_DAYS,
80
+ errorsPerDay: Number(baseline?.errors ?? 0) / BASELINE_DAYS,
81
+ },
82
+ from: from.toISOString(),
83
+ to: to.toISOString(),
84
+ }, 200);
85
+ }
86
+ catch (error) {
87
+ options.reporting.log.error({ err: error instanceof Error ? error.name : 'unknown' }, 'reporting: release health read failed');
88
+ return json({ error: 'read failed' }, 500);
89
+ }
90
+ };
91
+ }
92
+ // Any status under 500: the request is counted, and not as an error.
93
+ const CONTROL_FLOW_STATUS = 307;
94
+ function isNextControlFlow(error) {
95
+ try {
96
+ unstable_rethrow(error);
97
+ return false;
98
+ }
99
+ catch {
100
+ // unstable_rethrow throws only for Next's internal control-flow errors.
101
+ return true;
102
+ }
103
+ }
104
+ /**
105
+ * Wraps a route handler so each response is counted. A handler that throws is
106
+ * counted as a 500 and the throw goes on. Next's own control-flow throws
107
+ * (`redirect()`, `notFound()` and the like) are counted as a request, not an
108
+ * error, so they cannot push a release toward rollback.
109
+ */
110
+ export function withRequestCount(reporting, handler) {
111
+ return async (...args) => {
112
+ try {
113
+ const response = await handler(...args);
114
+ reporting.requests.record(response.status);
115
+ return response;
116
+ }
117
+ catch (error) {
118
+ reporting.requests.record(isNextControlFlow(error) ? CONTROL_FLOW_STATUS : 500);
119
+ throw error;
120
+ }
121
+ };
122
+ }
@@ -0,0 +1,22 @@
1
+ import type { Db, Logger } from './types.js';
2
+ export interface RequestCounterOptions {
3
+ db: Db;
4
+ log: Logger;
5
+ site: string;
6
+ now: () => Date;
7
+ flushEveryMs: number;
8
+ }
9
+ /**
10
+ * Counts responses in memory and writes one upsert per minute bucket, so a
11
+ * request costs a map increment and the database sees a handful of rows a
12
+ * minute. `errors` is status >= 500. A failed write puts its counts back for
13
+ * the next one; the process dying loses at most `flushEveryMs` of counts.
14
+ */
15
+ export declare class RequestCounter {
16
+ private readonly o;
17
+ private readonly buckets;
18
+ private timer;
19
+ constructor(o: RequestCounterOptions);
20
+ record(status: number): void;
21
+ flush(): Promise<void>;
22
+ }
@@ -0,0 +1,72 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { deployTags } from './deploy.js';
3
+ import { reportingRequests } from './tables.js';
4
+ /**
5
+ * Counts responses in memory and writes one upsert per minute bucket, so a
6
+ * request costs a map increment and the database sees a handful of rows a
7
+ * minute. `errors` is status >= 500. A failed write puts its counts back for
8
+ * the next one; the process dying loses at most `flushEveryMs` of counts.
9
+ */
10
+ export class RequestCounter {
11
+ o;
12
+ buckets = new Map();
13
+ timer = null;
14
+ constructor(o) {
15
+ this.o = o;
16
+ }
17
+ record(status) {
18
+ const minute = Math.floor(this.o.now().getTime() / 60_000) * 60_000;
19
+ const b = this.buckets.get(minute) ?? { requests: 0, errors: 0 };
20
+ b.requests += 1;
21
+ if (status >= 500)
22
+ b.errors += 1;
23
+ this.buckets.set(minute, b);
24
+ if (!this.timer) {
25
+ this.timer = setTimeout(() => {
26
+ this.timer = null;
27
+ void this.flush();
28
+ }, this.o.flushEveryMs);
29
+ this.timer.unref?.();
30
+ }
31
+ }
32
+ async flush() {
33
+ if (this.buckets.size === 0)
34
+ return;
35
+ const taken = [...this.buckets];
36
+ this.buckets.clear();
37
+ const { color, release } = deployTags();
38
+ try {
39
+ await this.o.db
40
+ .insert(reportingRequests)
41
+ .values(taken.map(([minute, c]) => ({
42
+ bucket: new Date(minute),
43
+ site: this.o.site,
44
+ color: color ?? '',
45
+ release: release ?? '',
46
+ requests: c.requests,
47
+ errors: c.errors,
48
+ })))
49
+ .onConflictDoUpdate({
50
+ target: [
51
+ reportingRequests.bucket,
52
+ reportingRequests.site,
53
+ reportingRequests.color,
54
+ reportingRequests.release,
55
+ ],
56
+ set: {
57
+ requests: sql `${reportingRequests.requests} + excluded.requests`,
58
+ errors: sql `${reportingRequests.errors} + excluded.errors`,
59
+ },
60
+ });
61
+ }
62
+ catch (error) {
63
+ for (const [minute, c] of taken) {
64
+ const b = this.buckets.get(minute) ?? { requests: 0, errors: 0 };
65
+ b.requests += c.requests;
66
+ b.errors += c.errors;
67
+ this.buckets.set(minute, b);
68
+ }
69
+ this.o.log.error({ err: error instanceof Error ? error.name : 'unknown' }, 'reporting: request counter flush failed');
70
+ }
71
+ }
72
+ }
package/dist/tables.d.ts CHANGED
@@ -130,6 +130,40 @@ export declare const reportingEvents: import("drizzle-orm/pg-core").PgTableWithC
130
130
  identity: undefined;
131
131
  generated: undefined;
132
132
  }, {}, {}>;
133
+ release: import("drizzle-orm/pg-core").PgColumn<{
134
+ name: "release";
135
+ tableName: "reporting_events";
136
+ dataType: "string";
137
+ columnType: "PgText";
138
+ data: string;
139
+ driverParam: string;
140
+ notNull: false;
141
+ hasDefault: false;
142
+ isPrimaryKey: false;
143
+ isAutoincrement: false;
144
+ hasRuntimeDefault: false;
145
+ enumValues: [string, ...string[]];
146
+ baseColumn: never;
147
+ identity: undefined;
148
+ generated: undefined;
149
+ }, {}, {}>;
150
+ color: import("drizzle-orm/pg-core").PgColumn<{
151
+ name: "color";
152
+ tableName: "reporting_events";
153
+ dataType: "string";
154
+ columnType: "PgText";
155
+ data: string;
156
+ driverParam: string;
157
+ notNull: false;
158
+ hasDefault: false;
159
+ isPrimaryKey: false;
160
+ isAutoincrement: false;
161
+ hasRuntimeDefault: false;
162
+ enumValues: [string, ...string[]];
163
+ baseColumn: never;
164
+ identity: undefined;
165
+ generated: undefined;
166
+ }, {}, {}>;
133
167
  tenantId: import("drizzle-orm/pg-core").PgColumn<{
134
168
  name: "tenant_id";
135
169
  tableName: "reporting_events";
@@ -878,6 +912,40 @@ export declare const reportingAnalytics: import("drizzle-orm/pg-core").PgTableWi
878
912
  identity: undefined;
879
913
  generated: undefined;
880
914
  }, {}, {}>;
915
+ release: import("drizzle-orm/pg-core").PgColumn<{
916
+ name: "release";
917
+ tableName: "reporting_analytics";
918
+ dataType: "string";
919
+ columnType: "PgText";
920
+ data: string;
921
+ driverParam: string;
922
+ notNull: false;
923
+ hasDefault: false;
924
+ isPrimaryKey: false;
925
+ isAutoincrement: false;
926
+ hasRuntimeDefault: false;
927
+ enumValues: [string, ...string[]];
928
+ baseColumn: never;
929
+ identity: undefined;
930
+ generated: undefined;
931
+ }, {}, {}>;
932
+ color: import("drizzle-orm/pg-core").PgColumn<{
933
+ name: "color";
934
+ tableName: "reporting_analytics";
935
+ dataType: "string";
936
+ columnType: "PgText";
937
+ data: string;
938
+ driverParam: string;
939
+ notNull: false;
940
+ hasDefault: false;
941
+ isPrimaryKey: false;
942
+ isAutoincrement: false;
943
+ hasRuntimeDefault: false;
944
+ enumValues: [string, ...string[]];
945
+ baseColumn: never;
946
+ identity: undefined;
947
+ generated: undefined;
948
+ }, {}, {}>;
881
949
  };
882
950
  dialect: "pg";
883
951
  }>;
@@ -1494,6 +1562,23 @@ export declare const reportingErrors: import("drizzle-orm/pg-core").PgTableWithC
1494
1562
  identity: undefined;
1495
1563
  generated: undefined;
1496
1564
  }, {}, {}>;
1565
+ color: import("drizzle-orm/pg-core").PgColumn<{
1566
+ name: "color";
1567
+ tableName: "reporting_errors";
1568
+ dataType: "string";
1569
+ columnType: "PgText";
1570
+ data: string;
1571
+ driverParam: string;
1572
+ notNull: false;
1573
+ hasDefault: false;
1574
+ isPrimaryKey: false;
1575
+ isAutoincrement: false;
1576
+ hasRuntimeDefault: false;
1577
+ enumValues: [string, ...string[]];
1578
+ baseColumn: never;
1579
+ identity: undefined;
1580
+ generated: undefined;
1581
+ }, {}, {}>;
1497
1582
  firstSeenAt: import("drizzle-orm/pg-core").PgColumn<{
1498
1583
  name: "first_seen_at";
1499
1584
  tableName: "reporting_errors";
@@ -1741,17 +1826,128 @@ export type ReportingErrorRow = typeof reportingErrors.$inferSelect;
1741
1826
  * Counts, times and
1742
1827
  * `tenantId` are the tenant's own (reporting_error_tenants).
1743
1828
  */
1744
- export type TenantErrorRow = Omit<ReportingErrorRow, 'message' | 'stack' | 'runtime' | 'release' | 'requestId' | 'resolvedBy' | 'state' | 'resolvedAt'> & {
1829
+ export type TenantErrorRow = Omit<ReportingErrorRow, 'message' | 'stack' | 'runtime' | 'release' | 'color' | 'requestId' | 'resolvedBy' | 'state' | 'resolvedAt'> & {
1745
1830
  readonly message: null;
1746
1831
  readonly stack: null;
1747
1832
  readonly runtime: null;
1748
1833
  readonly release: null;
1834
+ readonly color: null;
1749
1835
  readonly requestId: null;
1750
1836
  readonly resolvedBy: null;
1751
1837
  /** Triage state is shared across tenants, so a tenant cannot see it. */
1752
1838
  readonly state: null;
1753
1839
  readonly resolvedAt: null;
1754
1840
  };
1841
+ /** Request and 5xx counts per minute, color and release; mirrored from migrations/0010_requests.sql. */
1842
+ export declare const reportingRequests: import("drizzle-orm/pg-core").PgTableWithColumns<{
1843
+ name: "reporting_requests";
1844
+ schema: undefined;
1845
+ columns: {
1846
+ bucket: import("drizzle-orm/pg-core").PgColumn<{
1847
+ name: "bucket";
1848
+ tableName: "reporting_requests";
1849
+ dataType: "date";
1850
+ columnType: "PgTimestamp";
1851
+ data: Date;
1852
+ driverParam: string;
1853
+ notNull: true;
1854
+ hasDefault: false;
1855
+ isPrimaryKey: false;
1856
+ isAutoincrement: false;
1857
+ hasRuntimeDefault: false;
1858
+ enumValues: undefined;
1859
+ baseColumn: never;
1860
+ identity: undefined;
1861
+ generated: undefined;
1862
+ }, {}, {}>;
1863
+ site: import("drizzle-orm/pg-core").PgColumn<{
1864
+ name: "site";
1865
+ tableName: "reporting_requests";
1866
+ dataType: "string";
1867
+ columnType: "PgText";
1868
+ data: string;
1869
+ driverParam: string;
1870
+ notNull: true;
1871
+ hasDefault: false;
1872
+ isPrimaryKey: false;
1873
+ isAutoincrement: false;
1874
+ hasRuntimeDefault: false;
1875
+ enumValues: [string, ...string[]];
1876
+ baseColumn: never;
1877
+ identity: undefined;
1878
+ generated: undefined;
1879
+ }, {}, {}>;
1880
+ color: import("drizzle-orm/pg-core").PgColumn<{
1881
+ name: "color";
1882
+ tableName: "reporting_requests";
1883
+ dataType: "string";
1884
+ columnType: "PgText";
1885
+ data: string;
1886
+ driverParam: string;
1887
+ notNull: true;
1888
+ hasDefault: true;
1889
+ isPrimaryKey: false;
1890
+ isAutoincrement: false;
1891
+ hasRuntimeDefault: false;
1892
+ enumValues: [string, ...string[]];
1893
+ baseColumn: never;
1894
+ identity: undefined;
1895
+ generated: undefined;
1896
+ }, {}, {}>;
1897
+ release: import("drizzle-orm/pg-core").PgColumn<{
1898
+ name: "release";
1899
+ tableName: "reporting_requests";
1900
+ dataType: "string";
1901
+ columnType: "PgText";
1902
+ data: string;
1903
+ driverParam: string;
1904
+ notNull: true;
1905
+ hasDefault: true;
1906
+ isPrimaryKey: false;
1907
+ isAutoincrement: false;
1908
+ hasRuntimeDefault: false;
1909
+ enumValues: [string, ...string[]];
1910
+ baseColumn: never;
1911
+ identity: undefined;
1912
+ generated: undefined;
1913
+ }, {}, {}>;
1914
+ requests: import("drizzle-orm/pg-core").PgColumn<{
1915
+ name: "requests";
1916
+ tableName: "reporting_requests";
1917
+ dataType: "number";
1918
+ columnType: "PgBigInt53";
1919
+ data: number;
1920
+ driverParam: string | number;
1921
+ notNull: true;
1922
+ hasDefault: true;
1923
+ isPrimaryKey: false;
1924
+ isAutoincrement: false;
1925
+ hasRuntimeDefault: false;
1926
+ enumValues: undefined;
1927
+ baseColumn: never;
1928
+ identity: undefined;
1929
+ generated: undefined;
1930
+ }, {}, {}>;
1931
+ errors: import("drizzle-orm/pg-core").PgColumn<{
1932
+ name: "errors";
1933
+ tableName: "reporting_requests";
1934
+ dataType: "number";
1935
+ columnType: "PgBigInt53";
1936
+ data: number;
1937
+ driverParam: string | number;
1938
+ notNull: true;
1939
+ hasDefault: true;
1940
+ isPrimaryKey: false;
1941
+ isAutoincrement: false;
1942
+ hasRuntimeDefault: false;
1943
+ enumValues: undefined;
1944
+ baseColumn: never;
1945
+ identity: undefined;
1946
+ generated: undefined;
1947
+ }, {}, {}>;
1948
+ };
1949
+ dialect: "pg";
1950
+ }>;
1755
1951
  export declare const tables: {
1756
1952
  events: import("drizzle-orm/pg-core").PgTableWithColumns<{
1757
1953
  name: "reporting_events";
@@ -1878,6 +2074,40 @@ export declare const tables: {
1878
2074
  identity: undefined;
1879
2075
  generated: undefined;
1880
2076
  }, {}, {}>;
2077
+ release: import("drizzle-orm/pg-core").PgColumn<{
2078
+ name: "release";
2079
+ tableName: "reporting_events";
2080
+ dataType: "string";
2081
+ columnType: "PgText";
2082
+ data: string;
2083
+ driverParam: string;
2084
+ notNull: false;
2085
+ hasDefault: false;
2086
+ isPrimaryKey: false;
2087
+ isAutoincrement: false;
2088
+ hasRuntimeDefault: false;
2089
+ enumValues: [string, ...string[]];
2090
+ baseColumn: never;
2091
+ identity: undefined;
2092
+ generated: undefined;
2093
+ }, {}, {}>;
2094
+ color: import("drizzle-orm/pg-core").PgColumn<{
2095
+ name: "color";
2096
+ tableName: "reporting_events";
2097
+ dataType: "string";
2098
+ columnType: "PgText";
2099
+ data: string;
2100
+ driverParam: string;
2101
+ notNull: false;
2102
+ hasDefault: false;
2103
+ isPrimaryKey: false;
2104
+ isAutoincrement: false;
2105
+ hasRuntimeDefault: false;
2106
+ enumValues: [string, ...string[]];
2107
+ baseColumn: never;
2108
+ identity: undefined;
2109
+ generated: undefined;
2110
+ }, {}, {}>;
1881
2111
  tenantId: import("drizzle-orm/pg-core").PgColumn<{
1882
2112
  name: "tenant_id";
1883
2113
  tableName: "reporting_events";
@@ -2624,6 +2854,40 @@ export declare const tables: {
2624
2854
  identity: undefined;
2625
2855
  generated: undefined;
2626
2856
  }, {}, {}>;
2857
+ release: import("drizzle-orm/pg-core").PgColumn<{
2858
+ name: "release";
2859
+ tableName: "reporting_analytics";
2860
+ dataType: "string";
2861
+ columnType: "PgText";
2862
+ data: string;
2863
+ driverParam: string;
2864
+ notNull: false;
2865
+ hasDefault: false;
2866
+ isPrimaryKey: false;
2867
+ isAutoincrement: false;
2868
+ hasRuntimeDefault: false;
2869
+ enumValues: [string, ...string[]];
2870
+ baseColumn: never;
2871
+ identity: undefined;
2872
+ generated: undefined;
2873
+ }, {}, {}>;
2874
+ color: import("drizzle-orm/pg-core").PgColumn<{
2875
+ name: "color";
2876
+ tableName: "reporting_analytics";
2877
+ dataType: "string";
2878
+ columnType: "PgText";
2879
+ data: string;
2880
+ driverParam: string;
2881
+ notNull: false;
2882
+ hasDefault: false;
2883
+ isPrimaryKey: false;
2884
+ isAutoincrement: false;
2885
+ hasRuntimeDefault: false;
2886
+ enumValues: [string, ...string[]];
2887
+ baseColumn: never;
2888
+ identity: undefined;
2889
+ generated: undefined;
2890
+ }, {}, {}>;
2627
2891
  };
2628
2892
  dialect: "pg";
2629
2893
  }>;
@@ -3237,6 +3501,23 @@ export declare const tables: {
3237
3501
  identity: undefined;
3238
3502
  generated: undefined;
3239
3503
  }, {}, {}>;
3504
+ color: import("drizzle-orm/pg-core").PgColumn<{
3505
+ name: "color";
3506
+ tableName: "reporting_errors";
3507
+ dataType: "string";
3508
+ columnType: "PgText";
3509
+ data: string;
3510
+ driverParam: string;
3511
+ notNull: false;
3512
+ hasDefault: false;
3513
+ isPrimaryKey: false;
3514
+ isAutoincrement: false;
3515
+ hasRuntimeDefault: false;
3516
+ enumValues: [string, ...string[]];
3517
+ baseColumn: never;
3518
+ identity: undefined;
3519
+ generated: undefined;
3520
+ }, {}, {}>;
3240
3521
  firstSeenAt: import("drizzle-orm/pg-core").PgColumn<{
3241
3522
  name: "first_seen_at";
3242
3523
  tableName: "reporting_errors";
@@ -3468,4 +3749,113 @@ export declare const tables: {
3468
3749
  };
3469
3750
  dialect: "pg";
3470
3751
  }>;
3752
+ requests: import("drizzle-orm/pg-core").PgTableWithColumns<{
3753
+ name: "reporting_requests";
3754
+ schema: undefined;
3755
+ columns: {
3756
+ bucket: import("drizzle-orm/pg-core").PgColumn<{
3757
+ name: "bucket";
3758
+ tableName: "reporting_requests";
3759
+ dataType: "date";
3760
+ columnType: "PgTimestamp";
3761
+ data: Date;
3762
+ driverParam: string;
3763
+ notNull: true;
3764
+ hasDefault: false;
3765
+ isPrimaryKey: false;
3766
+ isAutoincrement: false;
3767
+ hasRuntimeDefault: false;
3768
+ enumValues: undefined;
3769
+ baseColumn: never;
3770
+ identity: undefined;
3771
+ generated: undefined;
3772
+ }, {}, {}>;
3773
+ site: import("drizzle-orm/pg-core").PgColumn<{
3774
+ name: "site";
3775
+ tableName: "reporting_requests";
3776
+ dataType: "string";
3777
+ columnType: "PgText";
3778
+ data: string;
3779
+ driverParam: string;
3780
+ notNull: true;
3781
+ hasDefault: false;
3782
+ isPrimaryKey: false;
3783
+ isAutoincrement: false;
3784
+ hasRuntimeDefault: false;
3785
+ enumValues: [string, ...string[]];
3786
+ baseColumn: never;
3787
+ identity: undefined;
3788
+ generated: undefined;
3789
+ }, {}, {}>;
3790
+ color: import("drizzle-orm/pg-core").PgColumn<{
3791
+ name: "color";
3792
+ tableName: "reporting_requests";
3793
+ dataType: "string";
3794
+ columnType: "PgText";
3795
+ data: string;
3796
+ driverParam: string;
3797
+ notNull: true;
3798
+ hasDefault: true;
3799
+ isPrimaryKey: false;
3800
+ isAutoincrement: false;
3801
+ hasRuntimeDefault: false;
3802
+ enumValues: [string, ...string[]];
3803
+ baseColumn: never;
3804
+ identity: undefined;
3805
+ generated: undefined;
3806
+ }, {}, {}>;
3807
+ release: import("drizzle-orm/pg-core").PgColumn<{
3808
+ name: "release";
3809
+ tableName: "reporting_requests";
3810
+ dataType: "string";
3811
+ columnType: "PgText";
3812
+ data: string;
3813
+ driverParam: string;
3814
+ notNull: true;
3815
+ hasDefault: true;
3816
+ isPrimaryKey: false;
3817
+ isAutoincrement: false;
3818
+ hasRuntimeDefault: false;
3819
+ enumValues: [string, ...string[]];
3820
+ baseColumn: never;
3821
+ identity: undefined;
3822
+ generated: undefined;
3823
+ }, {}, {}>;
3824
+ requests: import("drizzle-orm/pg-core").PgColumn<{
3825
+ name: "requests";
3826
+ tableName: "reporting_requests";
3827
+ dataType: "number";
3828
+ columnType: "PgBigInt53";
3829
+ data: number;
3830
+ driverParam: string | number;
3831
+ notNull: true;
3832
+ hasDefault: true;
3833
+ isPrimaryKey: false;
3834
+ isAutoincrement: false;
3835
+ hasRuntimeDefault: false;
3836
+ enumValues: undefined;
3837
+ baseColumn: never;
3838
+ identity: undefined;
3839
+ generated: undefined;
3840
+ }, {}, {}>;
3841
+ errors: import("drizzle-orm/pg-core").PgColumn<{
3842
+ name: "errors";
3843
+ tableName: "reporting_requests";
3844
+ dataType: "number";
3845
+ columnType: "PgBigInt53";
3846
+ data: number;
3847
+ driverParam: string | number;
3848
+ notNull: true;
3849
+ hasDefault: true;
3850
+ isPrimaryKey: false;
3851
+ isAutoincrement: false;
3852
+ hasRuntimeDefault: false;
3853
+ enumValues: undefined;
3854
+ baseColumn: never;
3855
+ identity: undefined;
3856
+ generated: undefined;
3857
+ }, {}, {}>;
3858
+ };
3859
+ dialect: "pg";
3860
+ }>;
3471
3861
  };
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),
@@ -187,6 +192,20 @@ export const reportingErrorTenants = pgTable('reporting_error_tenants', {
187
192
  primaryKey({ name: 'reporting_error_tenants_pkey', columns: [t.fingerprint, t.tenantId] }),
188
193
  index('reporting_error_tenants_tenant_idx').on(t.tenantId, t.lastSeenAt.desc()),
189
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
+ ]);
190
209
  export const tables = {
191
210
  events: reportingEvents,
192
211
  tasks: reportingTasks,
@@ -197,4 +216,5 @@ export const tables = {
197
216
  analyticsWeekly: reportingAnalyticsWeekly,
198
217
  errors: reportingErrors,
199
218
  errorTenants: reportingErrorTenants,
219
+ requests: reportingRequests,
200
220
  };
package/dist/types.d.ts CHANGED
@@ -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;
@@ -198,6 +200,10 @@ export interface Reporting {
198
200
  readonly site: string;
199
201
  readonly log: Logger;
200
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
+ };
201
207
  stats(): Stats;
202
208
  }
203
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.7.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"
@@ -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;
@@ -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
- }