@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 +32 -6
- package/dist/analytics/write.js +3 -0
- package/dist/deploy.d.ts +11 -0
- package/dist/deploy.js +13 -0
- package/dist/errors/capture.js +4 -0
- package/dist/errors/reader.js +1 -0
- package/dist/index.js +13 -1
- package/dist/migrations/0009_release_color.sql +33 -0
- package/dist/migrations/0010_requests.sql +27 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/release-health.d.ts +22 -0
- package/dist/next/release-health.js +122 -0
- package/dist/requests.d.ts +22 -0
- package/dist/requests.js +72 -0
- package/dist/tables.d.ts +391 -1
- package/dist/tables.js +20 -0
- package/dist/types.d.ts +6 -0
- package/dist/writer.js +2 -0
- package/package.json +1 -5
- package/dist/fake/index.d.ts +0 -22
- package/dist/fake/index.js +0 -157
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
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
package/dist/analytics/write.js
CHANGED
|
@@ -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
|
}
|
package/dist/deploy.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors/capture.js
CHANGED
|
@@ -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,
|
package/dist/errors/reader.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
);
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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.
|
package/dist/next/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/requests.js
ADDED
|
@@ -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.
|
|
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"
|
package/dist/fake/index.d.ts
DELETED
|
@@ -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;
|
package/dist/fake/index.js
DELETED
|
@@ -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
|
-
}
|