cursedbelt-server 4.22.0 → 4.23.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.
@@ -82,6 +82,48 @@ export const DAYS_PER_MONTH = 30.4375;
82
82
  */
83
83
  export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
84
84
 
85
+ /**
86
+ * 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
87
+ * not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
88
+ * `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
89
+ * 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
90
+ * (since 09-22) served from Workers:
91
+ *
92
+ * ```
93
+ * script requests billed CPU-ms mean
94
+ * binary-server-edge-gate 754,974 1,805,750 2.39
95
+ * collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
96
+ * collections-stage 61 96,081 1575 (stage: seeding and gate runs)
97
+ * patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
98
+ * 3 others 196 126
99
+ * ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
100
+ * ```
101
+ *
102
+ * **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
103
+ * BELOW the shipped 9.9 — the account already carries more requests than the owner's
104
+ * 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
105
+ * requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
106
+ * that no app route budget governs. Averaging them in hands the app routes an allowance
107
+ * those requests never use. The question the default answers is what an APP request may
108
+ * spend once the measured non-app spend is set aside:
109
+ * `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
110
+ * = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
111
+ * edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
112
+ * account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
113
+ * App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
114
+ * anything from yet. `budget.spec.ts` holds both orderings as assertions.
115
+ */
116
+ export const MEASURED_WORKER_TRAFFIC = {
117
+ from: '2026-09-16T00:00:00Z',
118
+ until: '2026-09-23T00:00:00Z',
119
+ days: 7,
120
+ requests: 756_005,
121
+ cpuMs: 2_085_903,
122
+ /** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
123
+ edgeGateRequests: 754_974,
124
+ edgeGateCpuMs: 1_805_750,
125
+ } as const;
126
+
85
127
  /**
86
128
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
87
129
  * included CPU allowance.
@@ -99,9 +141,21 @@ export function deriveCpuBudgetMs(
99
141
  requestsPerMonth?: number;
100
142
  /** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
101
143
  requestsPerDay?: number;
144
+ /**
145
+ * CPU-ms per month already spent by traffic OUTSIDE the budgeted routes (measured —
146
+ * the edge gate in {@link MEASURED_WORKER_TRAFFIC}), taken off the allowance first.
147
+ * Default: 0.
148
+ */
149
+ reservedCpuMsPerMonth?: number;
102
150
  } = {},
103
151
  ): number {
104
- const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
152
+ const reserved = opts.reservedCpuMsPerMonth ?? 0;
153
+ const includedCpuMs = (opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS) - reserved;
154
+ if (!(reserved >= 0) || !(includedCpuMs > 0)) {
155
+ throw new Error(
156
+ `deriveCpuBudgetMs: reservedCpuMsPerMonth (${reserved}) must be ≥ 0 and leave some allowance`,
157
+ );
158
+ }
105
159
  const requestsPerMonth =
106
160
  opts.requestsPerMonth ??
107
161
  (opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
@@ -136,6 +190,9 @@ export function monthlyOverageUsd(opts: {
136
190
  *
137
191
  * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
138
192
  * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
193
+ *
194
+ * Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
195
+ * has the numbers (16.9 ms per app request after the edge gate's measured spend).
139
196
  */
140
197
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
141
198
 
@@ -23,11 +23,23 @@ import { BENCH_ORIGIN, runCpuBench } from './runBench.js';
23
23
  * timing would make the suite flaky for no extra truth.
24
24
  */
25
25
 
26
- /** Burn approximately `ms` of CPU. Deliberately un-optimizable: the result escapes. */
26
+ /**
27
+ * Burn at least `ms` of CPU. Deliberately un-optimizable: the result escapes.
28
+ *
29
+ * 🔴 The deadline is on the CPU clock, not the wall clock. The recorder measures
30
+ * `process.cpuUsage`; a burner that stops at a WALL deadline hands it only the share of
31
+ * those milliseconds the scheduler granted, so under load (measured 2026-09-23, load 17 on
32
+ * 14 cores) a 12 ms burn read 5.5 CPU-ms and reddened `row.max > 6`. Spinning until the
33
+ * process has actually been charged `ms` makes every lower bound here hold at any load.
34
+ */
27
35
  function burnCpu(ms: number): number {
28
- const deadline = performance.now() + ms;
36
+ const start = process.cpuUsage();
37
+ const spent = () => {
38
+ const d = process.cpuUsage(start);
39
+ return (d.user + d.system) / 1000;
40
+ };
29
41
  let acc = 0;
30
- while (performance.now() < deadline) {
42
+ while (spent() < ms) {
31
43
  for (let i = 0; i < 2_000; i += 1) acc += Math.sqrt(i + acc % 7);
32
44
  }
33
45
  return acc;
@@ -50,6 +50,7 @@ export {
50
50
  deriveCpuBudgetMs,
51
51
  isExemption,
52
52
  MEASURED_FLEET_REQUESTS_PER_DAY,
53
+ MEASURED_WORKER_TRAFFIC,
53
54
  monthlyOverageUsd,
54
55
  OWNER_PROJECTED_REQUESTS_PER_DAY,
55
56
  type ResolvedBudget,
@@ -20,8 +20,19 @@ export interface MetricRow {
20
20
  durationMs: number | null;
21
21
  bytesOut: number | null;
22
22
  userId: string | null;
23
+ /**
24
+ * Where the request came from — a PLACE, never an address (4.23.0). Filled by
25
+ * `requestLogger`'s `locate` hook; absent/null when nothing could place it. There is
26
+ * deliberately no `ip` field: the raw address is never stored, anywhere.
27
+ */
28
+ country?: string | null;
29
+ region?: string | null;
30
+ city?: string | null;
23
31
  }
24
32
 
33
+ /** The optional place columns, in insert order. Each is written only when the table HAS it. */
34
+ export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
35
+
25
36
  export interface MetricsBufferOptions {
26
37
  db: Database;
27
38
  /** Flush cadence in ms. Default: 1500. */
@@ -45,9 +56,17 @@ export function createMetricsBuffer(opts: MetricsBufferOptions): MetricsBuffer {
45
56
  const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
46
57
  let buffer: MetricRow[] = [];
47
58
 
59
+ // 🔴 The place columns are written only where the table has them (4.23.0). An app that
60
+ // created `request_metrics` with its own DDL before they existed keeps working unchanged —
61
+ // a fixed column list would make `prepare` throw on its first boot after the bump.
62
+ const present = new Set(
63
+ (db.query('PRAGMA table_info(request_metrics)').all() as Array<{ name: string }>).map((c) => c.name),
64
+ );
65
+ const place = PLACE_COLUMNS.filter((c) => present.has(c));
66
+ const columns = ['id', 'ts', 'method', 'route', 'status', 'duration_ms', 'bytes_out', 'user_id', ...place];
48
67
  const insert = db.prepare(
49
- `INSERT INTO request_metrics (id, ts, method, route, status, duration_ms, bytes_out, user_id)
50
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
68
+ `INSERT INTO request_metrics (${columns.join(', ')})
69
+ VALUES (${columns.map(() => '?').join(', ')})`,
51
70
  );
52
71
  const insertMany = db.transaction((rows: MetricRow[]) => {
53
72
  for (const r of rows) {
@@ -60,6 +79,7 @@ export function createMetricsBuffer(opts: MetricsBufferOptions): MetricsBuffer {
60
79
  r.durationMs,
61
80
  r.bytesOut,
62
81
  r.userId,
82
+ ...place.map((c) => r[c] ?? null),
63
83
  );
64
84
  }
65
85
  });
@@ -0,0 +1,142 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { describe, expect, it } from 'bun:test';
3
+ import { Hono } from 'hono';
4
+ import { requestLogger } from '../middleware/requestLogger.js';
5
+ import { createMetricsBuffer } from './metricsBuffer.js';
6
+ import { locateFromCloudflare, normalizePlace, PLACE_FIELD_MAX_CHARS, type RequestLocator } from './requestPlace.js';
7
+ import { type AnalyticsEngineDataset, TELEMETRY_POINT_VERSION, toDataPoint } from './telemetrySink.js';
8
+
9
+ /**
10
+ * Task 2123: a request is recorded with WHERE it came from — as a place, never as an address.
11
+ * Each claim here has its failure path beside it.
12
+ */
13
+
14
+ type Point = Parameters<AnalyticsEngineDataset['writeDataPoint']>[0];
15
+ const fakeDataset = () => {
16
+ const points: Point[] = [];
17
+ return { points, writeDataPoint: (p: Point) => void points.push(p) };
18
+ };
19
+
20
+ /** A Worker request: workerd hangs Cloudflare's geolocation off `request.cf`. */
21
+ const workerRequest = (url: string, cf: Record<string, unknown>): Request => {
22
+ const request = new Request(url);
23
+ Object.defineProperty(request, 'cf', { value: cf });
24
+ return request;
25
+ };
26
+
27
+ const LEGACY_DDL = `CREATE TABLE request_metrics (
28
+ id TEXT PRIMARY KEY, ts INTEGER NOT NULL, method TEXT, route TEXT,
29
+ status INTEGER, duration_ms REAL, bytes_out INTEGER, user_id TEXT)`;
30
+ const PLACED_DDL = LEGACY_DDL.replace('user_id TEXT)', 'user_id TEXT, country TEXT, region TEXT, city TEXT)');
31
+
32
+ describe('normalizePlace — a place and nothing else', () => {
33
+ it('🔴 copies only country/region/city: an ip a locator returns can never be written', () => {
34
+ const leaky = { country: 'IE', city: 'Dublin', ip: '203.0.113.9' } as never;
35
+ const place = normalizePlace(leaky);
36
+ expect(place).toEqual({ country: 'IE', region: null, city: 'Dublin' });
37
+ expect(JSON.stringify(place)).not.toContain('203.0.113.9');
38
+ });
39
+
40
+ it("an unknown country ('XX', '', whitespace) is no place at all — region/city are dropped with it", () => {
41
+ for (const country of ['XX', 'xx', '', ' ']) {
42
+ expect(normalizePlace({ country, city: 'Nowhere' })).toEqual({ country: null, region: null, city: null });
43
+ }
44
+ expect(normalizePlace(null)).toEqual({ country: null, region: null, city: null });
45
+ });
46
+
47
+ it('caps each field so three of them cannot crowd the Analytics Engine blob budget', () => {
48
+ const place = normalizePlace({ country: 'US', city: 'x'.repeat(5000) });
49
+ expect(place.city?.length).toBe(PLACE_FIELD_MAX_CHARS);
50
+ });
51
+ });
52
+
53
+ describe('locateFromCloudflare — the default reads the edge', () => {
54
+ it('on a Worker: request.cf.country/region/city', () => {
55
+ const request = workerRequest('https://app.test/', { country: 'PT', region: 'Lisbon', city: 'Lisbon' });
56
+ expect(locateFromCloudflare(request)).toEqual({ country: 'PT', region: 'Lisbon', city: 'Lisbon' });
57
+ });
58
+
59
+ it('behind the tunnel: the cf-ipcountry header, and the finer headers when the zone sends them (percent-decoded)', () => {
60
+ const coarse = new Request('https://app.test/', { headers: { 'cf-ipcountry': 'BR' } });
61
+ expect(normalizePlace(locateFromCloudflare(coarse))).toEqual({ country: 'BR', region: null, city: null });
62
+ const fine = new Request('https://app.test/', {
63
+ headers: { 'cf-ipcountry': 'BR', 'cf-region': 'S%C3%A3o%20Paulo', 'cf-ipcity': 'S%C3%A3o%20Paulo' },
64
+ });
65
+ expect(locateFromCloudflare(fine)).toEqual({ country: 'BR', region: 'São Paulo', city: 'São Paulo' });
66
+ });
67
+
68
+ it('FAILURE PATH: a direct request (dev, tests) is unplaced, not invented', () => {
69
+ expect(locateFromCloudflare(new Request('http://localhost/'))).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe('the Analytics Engine wire format — v2 appends the place at blob4–6', () => {
74
+ it('toDataPoint puts country/region/city in blob4/5/6 and leaves blob1–3 where they were', () => {
75
+ expect(TELEMETRY_POINT_VERSION).toBe(2);
76
+ const { blobs } = toDataPoint({
77
+ ts: 1, method: 'GET', route: '/', status: 200, durationMs: 1, bytesOut: 0, userId: 'u',
78
+ country: 'JP', region: 'Tokyo', city: 'Shibuya',
79
+ });
80
+ expect(blobs).toEqual(['GET', '/', 'u', 'JP', 'Tokyo', 'Shibuya']);
81
+ });
82
+
83
+ it('a Worker request is placed from request.cf with no app code at all', async () => {
84
+ const ae = fakeDataset();
85
+ const app = new Hono();
86
+ app.use('*', requestLogger(null, { analytics: ae }));
87
+ app.get('/', (c) => c.text('ok'));
88
+ await app.fetch(workerRequest('https://app.test/', { country: 'NZ', city: 'Wellington' }));
89
+ expect(ae.points[0].blobs?.slice(3)).toEqual(['NZ', '', 'Wellington']);
90
+ });
91
+
92
+ it('FAILURE PATH: a request the edge could not place writes empty place blobs, not a missing position', async () => {
93
+ const ae = fakeDataset();
94
+ const app = new Hono();
95
+ app.use('*', requestLogger(null, { analytics: ae }));
96
+ app.get('/', (c) => c.text('ok'));
97
+ await app.fetch(new Request('https://app.test/'));
98
+ expect(ae.points[0].blobs).toHaveLength(6);
99
+ expect(ae.points[0].blobs?.slice(3)).toEqual(['', '', '']);
100
+ });
101
+ });
102
+
103
+ describe('the SQLite sink — place columns where the table has them', () => {
104
+ const serve = async (db: Database, locate?: RequestLocator, request = new Request('http://app.test/x')) => {
105
+ const buffer = createMetricsBuffer({ db });
106
+ const app = new Hono();
107
+ app.use('*', requestLogger(null, { sink: buffer, ...(locate ? { locate } : {}) }));
108
+ app.get('/x', (c) => c.text('ok'));
109
+ expect((await app.fetch(request)).status).toBe(200);
110
+ buffer.stop();
111
+ return db.query('SELECT * FROM request_metrics').all() as Record<string, unknown>[];
112
+ };
113
+
114
+ it("an app's locate hook gets the Request and its place lands in country/region/city", async () => {
115
+ const db = new Database(':memory:');
116
+ db.run(PLACED_DDL);
117
+ let seen: Request | null = null;
118
+ const rows = await serve(db, (request) => {
119
+ seen = request;
120
+ return { country: 'IE', region: 'Leinster', city: 'Dublin' };
121
+ });
122
+ expect(seen).toBeInstanceOf(Request);
123
+ expect(rows).toEqual([expect.objectContaining({ route: '/x', country: 'IE', region: 'Leinster', city: 'Dublin' })]);
124
+ });
125
+
126
+ it('a locate that THROWS records the request unplaced — it never fails the request or loses the row', async () => {
127
+ const db = new Database(':memory:');
128
+ db.run(PLACED_DDL);
129
+ const rows = await serve(db, () => {
130
+ throw new Error('snapshot missing');
131
+ });
132
+ expect(rows).toEqual([expect.objectContaining({ route: '/x', status: 200, country: null, region: null, city: null })]);
133
+ });
134
+
135
+ it("🔴 a table created by an app's OWN older DDL (no place columns) keeps working after the bump", async () => {
136
+ const db = new Database(':memory:');
137
+ db.run(LEGACY_DDL);
138
+ const rows = await serve(db, () => ({ country: 'IE' }));
139
+ expect(rows).toHaveLength(1);
140
+ expect(Object.keys(rows[0])).not.toContain('country');
141
+ });
142
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where a request came from — as a PLACE, never as an address (4.23.0, task 2123).
3
+ *
4
+ * ── 🔴 The raw IP is never stored ───────────────────────────────────────────
5
+ * The retired instances' logger wrote `ip` on every row. This one does not, anywhere: not in
6
+ * `request_metrics`, not in an Analytics Engine blob. A {@link RequestLocator} receives the
7
+ * `Request`, may read whatever address it needs from it (`cf-connecting-ip` behind the tunnel),
8
+ * and returns only `{ country, region?, city? }`. The address dies with the request. That is
9
+ * the owner's privacy choice, fixed in the type rather than in a comment: {@link RequestPlace}
10
+ * has no field an address could go in, and {@link normalizePlace} copies only those three.
11
+ *
12
+ * ── The default reads the edge ──────────────────────────────────────────────
13
+ * {@link locateFromCloudflare} is what `requestLogger` uses when an app passes no `locate`:
14
+ *
15
+ * - on a Worker, `request.cf.country/region/city` — Cloudflare's own geolocation;
16
+ * - behind the tunnel (a Mac app), the `cf-ipcountry` header every proxied request carries,
17
+ * plus `cf-region`/`cf-ipcity` when the zone's "visitor location headers" transform is on.
18
+ *
19
+ * A Bun process reached directly (dev, tests) has neither, and gets `null` — an unplaced row,
20
+ * never an invented one. An app that can do better (station's DB-IP snapshot) passes its own
21
+ * `locate`, and should merge per field with this one so the edge wins wherever it speaks.
22
+ *
23
+ * Worker-safe: no `bun:` import (`telemetryIsWorkerSafe.spec.ts` walks this graph).
24
+ */
25
+
26
+ /** A place. `country` is an ISO-3166 alpha-2 code as the edge spells it (`US`, `IE`, `T1` for Tor). */
27
+ export interface RequestPlace {
28
+ country: string;
29
+ region?: string | null;
30
+ city?: string | null;
31
+ }
32
+
33
+ /** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
34
+ export type RequestLocator = (request: Request) => RequestPlace | null;
35
+
36
+ /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
37
+ export const PLACE_FIELD_MAX_CHARS = 96;
38
+
39
+ /** Cloudflare's "no country could be determined" code — an unknown, not a place. */
40
+ const UNKNOWN_COUNTRY = 'XX';
41
+
42
+ const clean = (value: unknown): string | null => {
43
+ if (typeof value !== 'string') return null;
44
+ const trimmed = value.trim().slice(0, PLACE_FIELD_MAX_CHARS);
45
+ return trimmed === '' ? null : trimmed;
46
+ };
47
+
48
+ /**
49
+ * The three columns a row stores, from whatever a locator returned. Copies ONLY
50
+ * `country/region/city` — so a locator that returns extra fields (an `ip`, say) cannot get them
51
+ * written — and treats an empty or unknown country as no place at all.
52
+ */
53
+ export function normalizePlace(place: RequestPlace | null | undefined): {
54
+ country: string | null;
55
+ region: string | null;
56
+ city: string | null;
57
+ } {
58
+ const country = clean(place?.country);
59
+ if (!country || country.toUpperCase() === UNKNOWN_COUNTRY) return { country: null, region: null, city: null };
60
+ return { country, region: clean(place?.region), city: clean(place?.city) };
61
+ }
62
+
63
+ /** Header values arrive percent-encoded when the edge had to (`cf-ipcity: S%C3%A3o%20Paulo`). */
64
+ const header = (request: Request, name: string): string | null => {
65
+ const raw = request.headers.get(name);
66
+ if (raw == null) return null;
67
+ try {
68
+ return decodeURIComponent(raw);
69
+ } catch {
70
+ return raw;
71
+ }
72
+ };
73
+
74
+ /** The edge's own answer: `request.cf` on a Worker, the `cf-*` headers behind the tunnel, else `null`. */
75
+ export const locateFromCloudflare: RequestLocator = (request) => {
76
+ const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
77
+ if (cf && typeof cf.country === 'string') {
78
+ return { country: cf.country, region: clean(cf.region), city: clean(cf.city) };
79
+ }
80
+ const country = header(request, 'cf-ipcountry');
81
+ if (!country) return null;
82
+ return { country, region: header(request, 'cf-region'), city: header(request, 'cf-ipcity') };
83
+ };
84
+
85
+ /** Run a locator the way the logger must: a throw is an unplaced request, never a failed one. */
86
+ export function placeOf(locate: RequestLocator, request: Request): ReturnType<typeof normalizePlace> {
87
+ try {
88
+ return normalizePlace(locate(request));
89
+ } catch {
90
+ return normalizePlace(null);
91
+ }
92
+ }
@@ -145,7 +145,8 @@ describe('toDataPoint — the layout is a wire format', () => {
145
145
  }),
146
146
  ).toEqual({
147
147
  indexes: ['/api/v1/sync/requested'],
148
- blobs: ['POST', '/api/v1/sync/requested', 'u-1'],
148
+ // blob4–6 (v2) are the place; '' when unplaced, never a null hole.
149
+ blobs: ['POST', '/api/v1/sync/requested', 'u-1', '', '', ''],
149
150
  // bytesOut null → -1. `0` is a real value for a 204, so it cannot be the marker.
150
151
  doubles: [204, 12.5, -1],
151
152
  });
@@ -97,8 +97,13 @@ export interface TelemetrySinkOptions {
97
97
  * index1 = route, truncated to 96 bytes — the sampling key, so sampling is per route
98
98
  * blob1 = method blob2 = route (untruncated) blob3 = userId ('' when anonymous)
99
99
  * double1 = status double2 = durationMs double3 = bytesOut
100
+ * blob4 = country blob5 = region blob6 = city (v2, 4.23.0)
100
101
  * ```
101
102
  *
103
+ * v2 APPENDED blob4–6: where the request came from, as a place and never an address
104
+ * (`./requestPlace.ts`). `''` when unplaced, like `userId`. A v1 query never reads past blob3,
105
+ * so it is unaffected; a query written against v2 reads `''` on points written before it.
106
+ *
102
107
  * `ts` is deliberately absent: Analytics Engine stamps its own `timestamp`
103
108
  * column, so sending ours would store the same instant twice.
104
109
  *
@@ -106,7 +111,7 @@ export interface TelemetrySinkOptions {
106
111
  * doubles array cannot hold a null and `0` is a real value for every one of them
107
112
  * (a 0-byte 204, notably). Read it as `NULL`, not as a measurement.
108
113
  */
109
- export const TELEMETRY_POINT_VERSION = 1;
114
+ export const TELEMETRY_POINT_VERSION = 2;
110
115
 
111
116
  const INDEX_MAX_BYTES = 96;
112
117
 
@@ -137,7 +142,14 @@ export function toDataPoint(event: TelemetryEvent): {
137
142
  const route = event.route ?? '';
138
143
  return {
139
144
  indexes: [truncateUtf8(route, INDEX_MAX_BYTES)],
140
- blobs: [event.method ?? '', route, event.userId ?? ''],
145
+ blobs: [
146
+ event.method ?? '',
147
+ route,
148
+ event.userId ?? '',
149
+ event.country ?? '',
150
+ event.region ?? '',
151
+ event.city ?? '',
152
+ ],
141
153
  doubles: [num(event.status), num(event.durationMs), num(event.bytesOut)],
142
154
  };
143
155
  }
@@ -10,6 +10,7 @@ import {
10
10
  createTelemetrySink,
11
11
  type TelemetrySink,
12
12
  } from '../metrics/telemetrySink.js';
13
+ import { locateFromCloudflare, placeOf, type RequestLocator } from '../metrics/requestPlace.js';
13
14
 
14
15
  /**
15
16
  * Structured request/response logging. After each request it records one
@@ -42,6 +43,13 @@ export interface RequestLoggerOpts {
42
43
  sink?: TelemetrySink;
43
44
  /** @deprecated Use {@link RequestLoggerOpts.sink} — a `MetricsBuffer` is one. Kept for callers that predate the sink. */
44
45
  buffer?: MetricsBuffer;
46
+ /**
47
+ * Where the request came from — returns a PLACE (`{ country, region?, city? }`) or `null`,
48
+ * never an address; the raw IP is not stored anywhere (`../metrics/requestPlace.ts`).
49
+ * Default: {@link locateFromCloudflare} — `request.cf` on a Worker, the `cf-ipcountry`
50
+ * header behind the tunnel. A throw is caught and the request recorded unplaced.
51
+ */
52
+ locate?: RequestLocator;
45
53
  /** Duration (ms) at/above which a 2xx request also logs an `event_logs` row. Default: 2000. */
46
54
  slowMs?: number;
47
55
  /** Also write `event_logs` rows for status ≥ 400. Default: true. */
@@ -69,6 +77,7 @@ export function requestLogger(
69
77
  opts.sink ?? opts.buffer ?? createTelemetrySink({ db, analytics: opts.analytics ?? null });
70
78
  const slowMs = opts.slowMs ?? 2000;
71
79
  const logErrors = opts.logErrors ?? true;
80
+ const locate = opts.locate ?? locateFromCloudflare;
72
81
 
73
82
  const insertEvent = db
74
83
  ? db.prepare(
@@ -96,7 +105,9 @@ export function requestLogger(
96
105
  const bytesOut = lenHeader ? Number(lenHeader) : null;
97
106
  const userId = c.get('userId') ?? null;
98
107
 
99
- sink.record({ ts: Date.now(), method, route, status, durationMs, bytesOut, userId });
108
+ const place = placeOf(locate, c.req.raw);
109
+
110
+ sink.record({ ts: Date.now(), method, route, status, durationMs, bytesOut, userId, ...place });
100
111
 
101
112
  const isError = status >= 400;
102
113
  const isSlow = durationMs >= slowMs;
@@ -204,3 +204,76 @@ describe("🔴 the inherited corpus is archived, never written", () => {
204
204
  expect(readFileSync(join(dir, `${RETIRED_METRICS_DB}-wal`), "utf8")).toBe("moved already");
205
205
  });
206
206
  });
207
+
208
+ describe("0002_request_place — where a request came from, never its address (task 2123)", () => {
209
+ const placed = (path = join(dir, METRICS_DB)) => {
210
+ const db = new Database(path, { readonly: true });
211
+ try {
212
+ return db.query("SELECT route, country, region, city FROM request_metrics ORDER BY ts").all();
213
+ } finally {
214
+ db.close();
215
+ }
216
+ };
217
+
218
+ test("a fresh corpus has nullable country/region/city and 🔴 no ip column", () => {
219
+ const m = track(openRequestMetrics(dir));
220
+ const columns = (m.db.query("PRAGMA table_info(request_metrics)").all() as { name: string; notnull: number }[]);
221
+ const byName = new Map(columns.map((c) => [c.name, c]));
222
+ for (const c of ["country", "region", "city"]) expect(byName.get(c)?.notnull).toBe(0);
223
+ expect(byName.has("ip")).toBe(false);
224
+ expect(JSON.stringify(m.db.query("SELECT name FROM _migrations").all())).toContain("0002_request_place");
225
+ });
226
+
227
+ test("a live corpus from before 0002 is UPGRADED in place — not archived, its rows kept and read as unplaced", async () => {
228
+ // Exactly what 4.21/4.22 left on disk: application_id stamped, 0001 applied, rows in it.
229
+ const first = track(openRequestMetrics(dir));
230
+ first.db.run("ALTER TABLE request_metrics DROP COLUMN country");
231
+ first.db.run("ALTER TABLE request_metrics DROP COLUMN region");
232
+ first.db.run("ALTER TABLE request_metrics DROP COLUMN city");
233
+ first.db.run("DELETE FROM _migrations WHERE name = '0002_request_place'");
234
+ first.db.run("INSERT INTO request_metrics (id, ts, method, route, status) VALUES ('old', 1, 'GET', '/before', 200)");
235
+ first.sink.stop();
236
+ first.db.close();
237
+ opened.splice(opened.indexOf(first), 1);
238
+
239
+ const second = track(openRequestMetrics(dir, { locate: () => ({ country: "IE", city: "Dublin" }) }));
240
+ expect(second.archived).toBeNull();
241
+ await appOver(second).fetch(new Request("http://app.test/healthz"));
242
+ second.sink.flush();
243
+ expect(placed()).toEqual([
244
+ { route: "/before", country: null, region: null, city: null },
245
+ { route: "/healthz", country: "IE", region: null, city: "Dublin" },
246
+ ]);
247
+ });
248
+
249
+ test("idempotent: a column already there (hand-added) is left alone and the step still records", () => {
250
+ const db = new Database(join(dir, METRICS_DB), { create: true });
251
+ db.run(`CREATE TABLE request_metrics (id TEXT PRIMARY KEY, ts INTEGER NOT NULL, method TEXT, route TEXT,
252
+ status INTEGER, duration_ms REAL, bytes_out INTEGER, user_id TEXT, country TEXT)`);
253
+ db.run(`PRAGMA application_id = ${0x4346524d}`); // the writer's marker, stamped after page 1 exists
254
+ db.run("CREATE TABLE _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
255
+ db.run("INSERT INTO _migrations VALUES ('0001_request_metrics', 'x')");
256
+ db.close();
257
+ const m = track(openRequestMetrics(dir));
258
+ expect(m.archived).toBeNull();
259
+ const names = (m.db.query("PRAGMA table_info(request_metrics)").all() as { name: string }[]).map((c) => c.name);
260
+ expect(names.filter((n) => n === "country")).toHaveLength(1);
261
+ expect(names).toEqual(expect.arrayContaining(["region", "city"]));
262
+ });
263
+
264
+ test("the default places a tunnelled request by its cf-ipcountry header; a direct one is unplaced", async () => {
265
+ const m = track(openRequestMetrics(dir));
266
+ const app = appOver(m);
267
+ await app.fetch(new Request("http://app.test/healthz", { headers: { "cf-ipcountry": "CA" } }));
268
+ await app.fetch(new Request("http://app.test/healthz"));
269
+ m.sink.flush();
270
+ expect(placed().map((r) => (r as { country: string | null }).country)).toEqual(["CA", null]);
271
+ });
272
+
273
+ test("openRequestMetricsFromEnv passes the locator through", async () => {
274
+ const m = track(openRequestMetricsFromEnv("app", { APP_DATA_DIR: dir }, { locate: () => ({ country: "FR" }) }));
275
+ await appOver(m).fetch(new Request("http://app.test/healthz"));
276
+ m.sink.flush();
277
+ expect(placed()).toEqual([{ route: "/healthz", country: "FR", region: null, city: null }]);
278
+ });
279
+ });
@@ -63,6 +63,8 @@ import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statS
63
63
  import { join } from "node:path";
64
64
  import { applyCcPragmas } from "cwip/sqlite";
65
65
  import type { MiddlewareHandler } from "hono";
66
+ import { PLACE_COLUMNS } from "../metrics/metricsBuffer.js";
67
+ import type { RequestLocator } from "../metrics/requestPlace.js";
66
68
  import { createTelemetrySink, type TelemetrySink } from "../metrics/telemetrySink.js";
67
69
  import { requestLogger } from "../middleware/requestLogger.js";
68
70
  import { runMigrations } from "../migration/runner.js";
@@ -97,6 +99,21 @@ const REQUEST_METRICS_MIGRATIONS: readonly Migration[] = [
97
99
  db.run("CREATE INDEX idx_request_metrics_ts ON request_metrics (ts)");
98
100
  },
99
101
  },
102
+ {
103
+ // 4.23.0, task 2123: where a request came from — a PLACE, never the address (no `ip`
104
+ // column, ever; `../metrics/requestPlace.ts`). Nullable, so every row written before
105
+ // this reads as unplaced. Idempotent beyond the runner's once-only record: a column
106
+ // already present (hand-added, or a half-applied copy of this step) is left alone.
107
+ name: "0002_request_place",
108
+ up(db) {
109
+ const have = new Set(
110
+ (db.query("PRAGMA table_info(request_metrics)").all() as { name: string }[]).map((c) => c.name),
111
+ );
112
+ for (const column of PLACE_COLUMNS) {
113
+ if (!have.has(column)) db.run(`ALTER TABLE request_metrics ADD COLUMN ${column} TEXT`);
114
+ }
115
+ },
116
+ },
100
117
  ];
101
118
 
102
119
  /** The `application_id` stamped in a SQLite file's header, or null when it has no header yet. */
@@ -161,8 +178,17 @@ export interface RequestMetrics {
161
178
  archived: string | null;
162
179
  }
163
180
 
181
+ export interface RequestMetricsOptions {
182
+ /**
183
+ * Place each request (`{ country, region?, city? }` or `null`) — never an address; the raw IP
184
+ * is not stored. Default: the edge's own answer, `locateFromCloudflare` (the `cf-ipcountry`
185
+ * header every tunnelled request carries). station passes its DB-IP reader here.
186
+ */
187
+ locate?: RequestLocator;
188
+ }
189
+
164
190
  /** Open the corpus in `dir` and build the logger that writes into it. */
165
- export function openRequestMetrics(dir: string): RequestMetrics {
191
+ export function openRequestMetrics(dir: string, options: RequestMetricsOptions = {}): RequestMetrics {
166
192
  const { db, archived } = openMetricsDb(dir);
167
193
  const inner = createTelemetrySink({ db });
168
194
  // 🔴 No user id — see the header. Nulled here so no context value can reach the row.
@@ -175,7 +201,10 @@ export function openRequestMetrics(dir: string): RequestMetrics {
175
201
  },
176
202
  };
177
203
  // `null` database: the logger writes through `sink` only, and no `event_logs`.
178
- const middleware = requestLogger(null, { sink }) as unknown as MiddlewareHandler;
204
+ const middleware = requestLogger(null, {
205
+ sink,
206
+ ...(options.locate ? { locate: options.locate } : {}),
207
+ }) as unknown as MiddlewareHandler;
179
208
  return { db, sink, middleware, archived };
180
209
  }
181
210
 
@@ -185,11 +214,15 @@ export function openRequestMetrics(dir: string): RequestMetrics {
185
214
  * records nothing rather than appending a developer's clicks to the live corpus.
186
215
  * Never throws: a failure is one stderr line and an app that serves unmeasured.
187
216
  */
188
- export function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv): RequestMetrics | null {
217
+ export function openRequestMetricsFromEnv(
218
+ name: string,
219
+ env: NodeJS.ProcessEnv,
220
+ options: RequestMetricsOptions = {},
221
+ ): RequestMetrics | null {
189
222
  const dir = env.APP_DATA_DIR?.trim();
190
223
  if (!dir || isTestRuntime(env)) return null;
191
224
  try {
192
- const metrics = openRequestMetrics(dir);
225
+ const metrics = openRequestMetrics(dir, options);
193
226
  if (metrics.archived) {
194
227
  console.log(`[${name}] request metrics: archived the inherited corpus to ${metrics.archived}`);
195
228
  }
@@ -25,3 +25,8 @@ export {
25
25
  type TelemetrySinkOptions,
26
26
  toDataPoint,
27
27
  } from './metrics/telemetrySink.js';
28
+ export {
29
+ locateFromCloudflare,
30
+ type RequestLocator,
31
+ type RequestPlace,
32
+ } from './metrics/requestPlace.js';