@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 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: { record: (status) => requests.record(status) },
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
+ }
@@ -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.
@@ -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: Pick<Reporting, 'requests'>, handler: (...args: A) => Promise<Response> | Response): (...args: A) => Promise<Response>;
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>;
@@ -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 += 1;
21
- if (status >= 500)
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
@@ -203,6 +203,7 @@ export interface Reporting {
203
203
  /** Counts one response for the release-health read; `flush` writes the counts. Never throws. */
204
204
  readonly requests: {
205
205
  record(status: number): void;
206
+ recordError(): void;
206
207
  };
207
208
  stats(): Stats;
208
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/reporting",
3
- "version": "0.8.1",
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",