@wtfalch/reporting 0.8.1 → 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 +27 -0
- package/dist/index.js +4 -1
- package/dist/next/count.d.ts +49 -0
- package/dist/next/count.js +100 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/release-health.d.ts +3 -1
- package/dist/requests.d.ts +7 -0
- package/dist/requests.js +13 -3
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -232,6 +232,33 @@ wrong token, 503 when the variable is unset. It answers `from` and `to` (ISO,
|
|
|
232
232
|
per color and the per-day average of the 7 days before `from`. Counts only; no
|
|
233
233
|
event content. Rows of `reporting_requests` are not pruned.
|
|
234
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
|
+
|
|
235
262
|
## Shutdown
|
|
236
263
|
|
|
237
264
|
```ts
|
package/dist/index.js
CHANGED
|
@@ -103,7 +103,10 @@ export function createReporting(options) {
|
|
|
103
103
|
await writer.flush(opts);
|
|
104
104
|
await requests.flush();
|
|
105
105
|
},
|
|
106
|
-
requests: {
|
|
106
|
+
requests: {
|
|
107
|
+
record: (status) => requests.record(status),
|
|
108
|
+
recordError: () => requests.recordError(),
|
|
109
|
+
},
|
|
107
110
|
events: { page: (opts) => eventsPage(options.db, opts) },
|
|
108
111
|
settings: {
|
|
109
112
|
get: () => getSettings(options.db),
|
|
@@ -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
|
@@ -4,6 +4,7 @@ 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
6
|
export { releaseHealthHandler, withRequestCount, type ReleaseHealthOptions, } from './release-health.js';
|
|
7
|
+
export { countRequestErrors, countRequests, DEFAULT_UNCOUNTED_PATHS, type CountOptions, } from './count.js';
|
|
7
8
|
export { edgeErrorHandler, type EdgeCaptureOptions, type EdgeErrorReport } from './edge-errors.js';
|
|
8
9
|
/**
|
|
9
10
|
* The Next bindings: everything in the package that knows what a request is.
|
package/dist/next/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { countryOf, deviceOf } from '../analytics/schema.js';
|
|
|
7
7
|
import { createClientErrorCollector } from './client-errors.js';
|
|
8
8
|
import { createEdgeErrorIngestCollector } from './edge-errors.js';
|
|
9
9
|
export { releaseHealthHandler, withRequestCount, } from './release-health.js';
|
|
10
|
+
export { countRequestErrors, countRequests, DEFAULT_UNCOUNTED_PATHS, } from './count.js';
|
|
10
11
|
export { edgeErrorHandler } from './edge-errors.js';
|
|
11
12
|
/**
|
|
12
13
|
* The Next bindings: everything in the package that knows what a request is.
|
|
@@ -19,4 +19,6 @@ export declare function releaseHealthHandler(options: ReleaseHealthOptions): (re
|
|
|
19
19
|
* (`redirect()`, `notFound()` and the like) are counted as a request, not an
|
|
20
20
|
* error, so they cannot push a release toward rollback.
|
|
21
21
|
*/
|
|
22
|
-
export declare function withRequestCount<A extends unknown[]>(reporting:
|
|
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>;
|
package/dist/requests.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ export declare class RequestCounter {
|
|
|
17
17
|
private readonly buckets;
|
|
18
18
|
private timer;
|
|
19
19
|
constructor(o: RequestCounterOptions);
|
|
20
|
+
/** Counts one response. */
|
|
20
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;
|
|
21
28
|
flush(): Promise<void>;
|
|
22
29
|
}
|
package/dist/requests.js
CHANGED
|
@@ -14,12 +14,22 @@ export class RequestCounter {
|
|
|
14
14
|
constructor(o) {
|
|
15
15
|
this.o = o;
|
|
16
16
|
}
|
|
17
|
+
/** Counts one response. */
|
|
17
18
|
record(status) {
|
|
19
|
+
this.add(status >= 500 ? 1 : 0, 1);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Counts one 5xx for a request already counted by `record`, such as a page
|
|
23
|
+
* that threw after the proxy let it through. Adds no request.
|
|
24
|
+
*/
|
|
25
|
+
recordError() {
|
|
26
|
+
this.add(1, 0);
|
|
27
|
+
}
|
|
28
|
+
add(errors, requests) {
|
|
18
29
|
const minute = Math.floor(this.o.now().getTime() / 60_000) * 60_000;
|
|
19
30
|
const b = this.buckets.get(minute) ?? { requests: 0, errors: 0 };
|
|
20
|
-
b.requests +=
|
|
21
|
-
|
|
22
|
-
b.errors += 1;
|
|
31
|
+
b.requests += requests;
|
|
32
|
+
b.errors += errors;
|
|
23
33
|
this.buckets.set(minute, b);
|
|
24
34
|
if (!this.timer) {
|
|
25
35
|
this.timer = setTimeout(() => {
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/reporting",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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",
|