@wtfalch/reporting 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -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 +16 -1
- package/dist/migrations/0009_release_color.sql +33 -0
- package/dist/migrations/0010_requests.sql +27 -0
- package/dist/next/count.d.ts +49 -0
- package/dist/next/count.js +100 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/release-health.d.ts +24 -0
- package/dist/next/release-health.js +122 -0
- package/dist/requests.d.ts +29 -0
- package/dist/requests.js +82 -0
- package/dist/tables.d.ts +391 -1
- package/dist/tables.js +20 -0
- package/dist/types.d.ts +7 -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,58 @@ 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
|
+
|
|
235
|
+
### Count every request (0.9.0)
|
|
236
|
+
|
|
237
|
+
`withRequestCount` counts one route handler. To count every request with no
|
|
238
|
+
change per route, add one line to `proxy.ts` and one to `instrumentation.ts`:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// src/proxy.ts: wrap the proxy function you already export
|
|
242
|
+
import { countRequests } from '@wtfalch/reporting/next';
|
|
243
|
+
export const proxy = countRequests(reporting, async (request) => { /* as before */ });
|
|
244
|
+
|
|
245
|
+
// src/instrumentation.ts: pass the handler you already export (Sentry's, say) as the second argument
|
|
246
|
+
import { countRequestErrors } from '@wtfalch/reporting/next';
|
|
247
|
+
export const onRequestError = countRequestErrors(reporting, Sentry.captureRequestError);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The proxy counts each request it sees with the status it answers with (a
|
|
251
|
+
redirect or a 403 is a request; its own throw is a 500). A request it passes
|
|
252
|
+
through is counted as a request there, and a page, route handler or Server
|
|
253
|
+
Action that then throws reaches `onRequestError`, which adds the 5xx. A route
|
|
254
|
+
handler that returns a 500 without throwing is not seen; use `withRequestCount`
|
|
255
|
+
there. `/api/health` and `/api/reporting/release-health` are not counted
|
|
256
|
+
(`skipPaths` replaces that list). Do not wrap a route in `withRequestCount`
|
|
257
|
+
when the proxy matcher covers it: it would be counted twice. In Next 16
|
|
258
|
+
`proxy.ts` runs on the Node runtime only, which is where the in-memory counter
|
|
259
|
+
and its Postgres flush work; on the Edge runtime both functions count nothing.
|
|
260
|
+
A counting failure is logged and never fails the request.
|
|
261
|
+
|
|
208
262
|
## Shutdown
|
|
209
263
|
|
|
210
264
|
```ts
|
|
@@ -291,11 +345,10 @@ the rollups hold nothing to erase. Call it in the host's erasure transaction.
|
|
|
291
345
|
|
|
292
346
|
## Tests
|
|
293
347
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
Same validation, no database; `fake.rows` is the table.
|
|
348
|
+
No package ships a stand-in for a service. `createFakeReporting` (same
|
|
349
|
+
validation, no database, `fake.rows` is the table) is this package's own
|
|
350
|
+
unexported test double, under `src/test/`, for its own test files only -- a
|
|
351
|
+
host writes its own double, or runs this package against a real database.
|
|
299
352
|
|
|
300
353
|
## What this package never does
|
|
301
354
|
|
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,14 @@ 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: {
|
|
107
|
+
record: (status) => requests.record(status),
|
|
108
|
+
recordError: () => requests.recordError(),
|
|
109
|
+
},
|
|
95
110
|
events: { page: (opts) => eventsPage(options.db, opts) },
|
|
96
111
|
settings: {
|
|
97
112
|
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
|
+
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Reporting } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Counting for every request, with no change per route. Two halves, because
|
|
4
|
+
* Next gives each half only part of the picture:
|
|
5
|
+
*
|
|
6
|
+
* - `countRequests` wraps the app's `proxy.ts` function. It runs on every
|
|
7
|
+
* matched request before the page or route, so it counts the request. It
|
|
8
|
+
* sees the status the proxy itself answers with (a redirect, a 403, a 500
|
|
9
|
+
* from its own throw); a request it lets through is a pass-through there,
|
|
10
|
+
* and the page's real status is not known yet.
|
|
11
|
+
* - `countRequestErrors` is `onRequestError` in `instrumentation.ts`. Next calls
|
|
12
|
+
* it for each error a page, route handler or Server Action throws, which is
|
|
13
|
+
* the 5xx; it adds an error to a request the proxy already counted.
|
|
14
|
+
*
|
|
15
|
+
* Runtime: in Next 16 `proxy.ts` runs on the Node runtime only (Edge is not
|
|
16
|
+
* supported there), and `onRequestError` runs in whichever runtime threw. The
|
|
17
|
+
* counter is an in-memory map flushed to Postgres, so it works in Node. In an
|
|
18
|
+
* Edge runtime the call is skipped (see `isEdge`): an Edge instance has no
|
|
19
|
+
* database and its own memory, so a count there would never be written.
|
|
20
|
+
*
|
|
21
|
+
* Do not also wrap a route in `withRequestCount` when the proxy matcher
|
|
22
|
+
* covers it: the request would be counted twice, and a throw as two errors.
|
|
23
|
+
* `withRequestCount` stays for a path the proxy matcher excludes.
|
|
24
|
+
*
|
|
25
|
+
* Neither function ever fails a request: a counting error is logged.
|
|
26
|
+
*/
|
|
27
|
+
/** The probe and the release-health read are not traffic; counting them would swamp the signal. */
|
|
28
|
+
export declare const DEFAULT_UNCOUNTED_PATHS: readonly string[];
|
|
29
|
+
export interface CountOptions {
|
|
30
|
+
/** Paths (exact, or a prefix followed by `/`) that are not counted. Replaces the default list. */
|
|
31
|
+
readonly skipPaths?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Wraps the app's proxy function: `export const proxy = countRequests(reporting, handler)`.
|
|
35
|
+
* Counts the status the proxy answers with; a throw counts as a 500 and goes on.
|
|
36
|
+
*/
|
|
37
|
+
export declare function countRequests<A extends {
|
|
38
|
+
url: string;
|
|
39
|
+
}, R extends {
|
|
40
|
+
status: number;
|
|
41
|
+
} | undefined>(reporting: Pick<Reporting, 'requests' | 'log'>, handler: (request: A) => Promise<R> | R, options?: CountOptions): (request: A) => Promise<R>;
|
|
42
|
+
type RequestErrorArgs = Parameters<import('./index.js').NextOnRequestError>;
|
|
43
|
+
/**
|
|
44
|
+
* `export const onRequestError = countRequestErrors(reporting)`. Pass the
|
|
45
|
+
* handler you already export (Sentry's, `errorHandler(reporting)`) as `next`
|
|
46
|
+
* and it still runs, after the count and whatever the count does.
|
|
47
|
+
*/
|
|
48
|
+
export declare function countRequestErrors(reporting: Pick<Reporting, 'requests' | 'log'>, next?: (...args: RequestErrorArgs) => void | Promise<void>, options?: CountOptions): (...args: RequestErrorArgs) => void | Promise<void>;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counting for every request, with no change per route. Two halves, because
|
|
3
|
+
* Next gives each half only part of the picture:
|
|
4
|
+
*
|
|
5
|
+
* - `countRequests` wraps the app's `proxy.ts` function. It runs on every
|
|
6
|
+
* matched request before the page or route, so it counts the request. It
|
|
7
|
+
* sees the status the proxy itself answers with (a redirect, a 403, a 500
|
|
8
|
+
* from its own throw); a request it lets through is a pass-through there,
|
|
9
|
+
* and the page's real status is not known yet.
|
|
10
|
+
* - `countRequestErrors` is `onRequestError` in `instrumentation.ts`. Next calls
|
|
11
|
+
* it for each error a page, route handler or Server Action throws, which is
|
|
12
|
+
* the 5xx; it adds an error to a request the proxy already counted.
|
|
13
|
+
*
|
|
14
|
+
* Runtime: in Next 16 `proxy.ts` runs on the Node runtime only (Edge is not
|
|
15
|
+
* supported there), and `onRequestError` runs in whichever runtime threw. The
|
|
16
|
+
* counter is an in-memory map flushed to Postgres, so it works in Node. In an
|
|
17
|
+
* Edge runtime the call is skipped (see `isEdge`): an Edge instance has no
|
|
18
|
+
* database and its own memory, so a count there would never be written.
|
|
19
|
+
*
|
|
20
|
+
* Do not also wrap a route in `withRequestCount` when the proxy matcher
|
|
21
|
+
* covers it: the request would be counted twice, and a throw as two errors.
|
|
22
|
+
* `withRequestCount` stays for a path the proxy matcher excludes.
|
|
23
|
+
*
|
|
24
|
+
* Neither function ever fails a request: a counting error is logged.
|
|
25
|
+
*/
|
|
26
|
+
/** The probe and the release-health read are not traffic; counting them would swamp the signal. */
|
|
27
|
+
export const DEFAULT_UNCOUNTED_PATHS = [
|
|
28
|
+
'/api/health',
|
|
29
|
+
'/api/reporting/release-health',
|
|
30
|
+
];
|
|
31
|
+
const isEdge = () => process.env.NEXT_RUNTIME === 'edge';
|
|
32
|
+
function isSkipped(pathname, skip) {
|
|
33
|
+
return skip.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
34
|
+
}
|
|
35
|
+
function pathnameOf(url) {
|
|
36
|
+
try {
|
|
37
|
+
return new URL(url).pathname;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A request path (`/x?y=1`) rather than a full URL, as onRequestError hands over.
|
|
41
|
+
return url.split('?')[0] ?? url;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function safely(reporting, what, fn) {
|
|
45
|
+
try {
|
|
46
|
+
fn();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
try {
|
|
50
|
+
reporting.log.error({ err: error instanceof Error ? error.name : 'unknown' }, `reporting: ${what} failed`);
|
|
51
|
+
}
|
|
52
|
+
catch (logError) {
|
|
53
|
+
// The logger itself threw; counting must still never fail a request.
|
|
54
|
+
console.error(`reporting: ${what} failed and so did the logger`, logError);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Wraps the app's proxy function: `export const proxy = countRequests(reporting, handler)`.
|
|
60
|
+
* Counts the status the proxy answers with; a throw counts as a 500 and goes on.
|
|
61
|
+
*/
|
|
62
|
+
export function countRequests(reporting, handler, options = {}) {
|
|
63
|
+
const skip = options.skipPaths ?? DEFAULT_UNCOUNTED_PATHS;
|
|
64
|
+
const count = (request, status) => safely(reporting, 'request count', () => {
|
|
65
|
+
if (isEdge() || isSkipped(pathnameOf(request.url), skip))
|
|
66
|
+
return;
|
|
67
|
+
reporting.requests.record(status);
|
|
68
|
+
});
|
|
69
|
+
return async (request) => {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await handler(request);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
count(request, 500);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
count(request, response?.status ?? 200);
|
|
79
|
+
return response;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `export const onRequestError = countRequestErrors(reporting)`. Pass the
|
|
84
|
+
* handler you already export (Sentry's, `errorHandler(reporting)`) as `next`
|
|
85
|
+
* and it still runs, after the count and whatever the count does.
|
|
86
|
+
*/
|
|
87
|
+
export function countRequestErrors(reporting, next, options = {}) {
|
|
88
|
+
const skip = options.skipPaths ?? DEFAULT_UNCOUNTED_PATHS;
|
|
89
|
+
return (...args) => {
|
|
90
|
+
safely(reporting, 'request error count', () => {
|
|
91
|
+
// A throw in the proxy is already counted by `countRequests` as a 500; Next also reports it here.
|
|
92
|
+
if (args[2].routeType === 'proxy')
|
|
93
|
+
return;
|
|
94
|
+
if (isEdge() || isSkipped(pathnameOf(args[1].path), skip))
|
|
95
|
+
return;
|
|
96
|
+
reporting.requests.recordError();
|
|
97
|
+
});
|
|
98
|
+
return next?.(...args);
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ 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';
|
|
7
|
+
export { countRequestErrors, countRequests, DEFAULT_UNCOUNTED_PATHS, type CountOptions, } from './count.js';
|
|
6
8
|
export { edgeErrorHandler, type EdgeCaptureOptions, type EdgeErrorReport } from './edge-errors.js';
|
|
7
9
|
/**
|
|
8
10
|
* The Next bindings: everything in the package that knows what a request is.
|
package/dist/next/index.js
CHANGED
|
@@ -6,6 +6,8 @@ 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';
|
|
10
|
+
export { countRequestErrors, countRequests, DEFAULT_UNCOUNTED_PATHS, } from './count.js';
|
|
9
11
|
export { edgeErrorHandler } from './edge-errors.js';
|
|
10
12
|
/**
|
|
11
13
|
* The Next bindings: everything in the package that knows what a request is.
|
|
@@ -0,0 +1,24 @@
|
|
|
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: {
|
|
23
|
+
requests: Pick<Reporting['requests'], 'record'>;
|
|
24
|
+
}, 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,29 @@
|
|
|
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
|
+
/** Counts one response. */
|
|
21
|
+
record(status: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Counts one 5xx for a request already counted by `record`, such as a page
|
|
24
|
+
* that threw after the proxy let it through. Adds no request.
|
|
25
|
+
*/
|
|
26
|
+
recordError(): void;
|
|
27
|
+
private add;
|
|
28
|
+
flush(): Promise<void>;
|
|
29
|
+
}
|