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.
- package/dist/server/bench/budget.d.ts +50 -0
- package/dist/server/bench/budget.js +49 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/metrics/metricsBuffer.d.ts +10 -0
- package/dist/server/metrics/metricsBuffer.js +11 -3
- package/dist/server/metrics/requestPlace.d.ts +48 -0
- package/dist/server/metrics/requestPlace.js +77 -0
- package/dist/server/metrics/telemetrySink.d.ts +6 -1
- package/dist/server/metrics/telemetrySink.js +14 -2
- package/dist/server/middleware/requestLogger.d.ts +8 -0
- package/dist/server/middleware/requestLogger.js +4 -1
- package/dist/server/requestLog/requestMetrics.d.ts +11 -2
- package/dist/server/requestLog/requestMetrics.js +22 -4
- package/dist/server/telemetry.d.ts +1 -0
- package/dist/server/telemetry.js +1 -0
- package/package.json +1 -1
- package/src/server/bench/budget.spec.ts +38 -0
- package/src/server/bench/budget.ts +58 -1
- package/src/server/bench/cpuBudget.spec.ts +15 -3
- package/src/server/bench/index.ts +1 -0
- package/src/server/metrics/metricsBuffer.ts +22 -2
- package/src/server/metrics/requestPlace.spec.ts +142 -0
- package/src/server/metrics/requestPlace.ts +92 -0
- package/src/server/metrics/telemetrySink.spec.ts +2 -1
- package/src/server/metrics/telemetrySink.ts +14 -2
- package/src/server/middleware/requestLogger.ts +12 -1
- package/src/server/requestLog/requestMetrics.spec.ts +73 -0
- package/src/server/requestLog/requestMetrics.ts +37 -4
- package/src/server/telemetry.ts +5 -0
|
@@ -74,6 +74,47 @@ export declare const DAYS_PER_MONTH = 30.4375;
|
|
|
74
74
|
* the default may never drift ABOVE the measured allowance without a red build.
|
|
75
75
|
*/
|
|
76
76
|
export declare const MEASURED_FLEET_REQUESTS_PER_DAY = 42971;
|
|
77
|
+
/**
|
|
78
|
+
* 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
|
|
79
|
+
* not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
|
|
80
|
+
* `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
|
|
81
|
+
* 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
|
|
82
|
+
* (since 09-22) served from Workers:
|
|
83
|
+
*
|
|
84
|
+
* ```
|
|
85
|
+
* script requests billed CPU-ms mean
|
|
86
|
+
* binary-server-edge-gate 754,974 1,805,750 2.39
|
|
87
|
+
* collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
|
|
88
|
+
* collections-stage 61 96,081 1575 (stage: seeding and gate runs)
|
|
89
|
+
* patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
|
|
90
|
+
* 3 others 196 126
|
|
91
|
+
* ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
|
|
95
|
+
* BELOW the shipped 9.9 — the account already carries more requests than the owner's
|
|
96
|
+
* 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
|
|
97
|
+
* requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
|
|
98
|
+
* that no app route budget governs. Averaging them in hands the app routes an allowance
|
|
99
|
+
* those requests never use. The question the default answers is what an APP request may
|
|
100
|
+
* spend once the measured non-app spend is set aside:
|
|
101
|
+
* `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
|
|
102
|
+
* = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
|
|
103
|
+
* edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
|
|
104
|
+
* account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
|
|
105
|
+
* App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
|
|
106
|
+
* anything from yet. `budget.spec.ts` holds both orderings as assertions.
|
|
107
|
+
*/
|
|
108
|
+
export declare const MEASURED_WORKER_TRAFFIC: {
|
|
109
|
+
readonly from: "2026-09-16T00:00:00Z";
|
|
110
|
+
readonly until: "2026-09-23T00:00:00Z";
|
|
111
|
+
readonly days: 7;
|
|
112
|
+
readonly requests: 756005;
|
|
113
|
+
readonly cpuMs: 2085903;
|
|
114
|
+
/** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
|
|
115
|
+
readonly edgeGateRequests: 754974;
|
|
116
|
+
readonly edgeGateCpuMs: 1805750;
|
|
117
|
+
};
|
|
77
118
|
/**
|
|
78
119
|
* Derive the average CPU-ms a single request may spend before the fleet exceeds its
|
|
79
120
|
* included CPU allowance.
|
|
@@ -90,6 +131,12 @@ export declare function deriveCpuBudgetMs(opts?: {
|
|
|
90
131
|
requestsPerMonth?: number;
|
|
91
132
|
/** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
|
|
92
133
|
requestsPerDay?: number;
|
|
134
|
+
/**
|
|
135
|
+
* CPU-ms per month already spent by traffic OUTSIDE the budgeted routes (measured —
|
|
136
|
+
* the edge gate in {@link MEASURED_WORKER_TRAFFIC}), taken off the allowance first.
|
|
137
|
+
* Default: 0.
|
|
138
|
+
*/
|
|
139
|
+
reservedCpuMsPerMonth?: number;
|
|
93
140
|
}): number;
|
|
94
141
|
/**
|
|
95
142
|
* What a month costs in overage once the included allowances are spent. Used by the
|
|
@@ -112,6 +159,9 @@ export declare function monthlyOverageUsd(opts: {
|
|
|
112
159
|
*
|
|
113
160
|
* 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
|
|
114
161
|
* derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
|
|
162
|
+
*
|
|
163
|
+
* Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
|
|
164
|
+
* has the numbers (16.9 ms per app request after the edge gate's measured spend).
|
|
115
165
|
*/
|
|
116
166
|
export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
|
|
117
167
|
/** A route that is measured against a number. */
|
|
@@ -74,6 +74,47 @@ export const DAYS_PER_MONTH = 30.4375;
|
|
|
74
74
|
* the default may never drift ABOVE the measured allowance without a red build.
|
|
75
75
|
*/
|
|
76
76
|
export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
|
|
77
|
+
/**
|
|
78
|
+
* 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
|
|
79
|
+
* not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
|
|
80
|
+
* `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
|
|
81
|
+
* 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
|
|
82
|
+
* (since 09-22) served from Workers:
|
|
83
|
+
*
|
|
84
|
+
* ```
|
|
85
|
+
* script requests billed CPU-ms mean
|
|
86
|
+
* binary-server-edge-gate 754,974 1,805,750 2.39
|
|
87
|
+
* collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
|
|
88
|
+
* collections-stage 61 96,081 1575 (stage: seeding and gate runs)
|
|
89
|
+
* patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
|
|
90
|
+
* 3 others 196 126
|
|
91
|
+
* ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
|
|
95
|
+
* BELOW the shipped 9.9 — the account already carries more requests than the owner's
|
|
96
|
+
* 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
|
|
97
|
+
* requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
|
|
98
|
+
* that no app route budget governs. Averaging them in hands the app routes an allowance
|
|
99
|
+
* those requests never use. The question the default answers is what an APP request may
|
|
100
|
+
* spend once the measured non-app spend is set aside:
|
|
101
|
+
* `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
|
|
102
|
+
* = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
|
|
103
|
+
* edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
|
|
104
|
+
* account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
|
|
105
|
+
* App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
|
|
106
|
+
* anything from yet. `budget.spec.ts` holds both orderings as assertions.
|
|
107
|
+
*/
|
|
108
|
+
export const MEASURED_WORKER_TRAFFIC = {
|
|
109
|
+
from: '2026-09-16T00:00:00Z',
|
|
110
|
+
until: '2026-09-23T00:00:00Z',
|
|
111
|
+
days: 7,
|
|
112
|
+
requests: 756_005,
|
|
113
|
+
cpuMs: 2_085_903,
|
|
114
|
+
/** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
|
|
115
|
+
edgeGateRequests: 754_974,
|
|
116
|
+
edgeGateCpuMs: 1_805_750,
|
|
117
|
+
};
|
|
77
118
|
/**
|
|
78
119
|
* Derive the average CPU-ms a single request may spend before the fleet exceeds its
|
|
79
120
|
* included CPU allowance.
|
|
@@ -84,7 +125,11 @@ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
|
|
|
84
125
|
* allowed to become the common case.
|
|
85
126
|
*/
|
|
86
127
|
export function deriveCpuBudgetMs(opts = {}) {
|
|
87
|
-
const
|
|
128
|
+
const reserved = opts.reservedCpuMsPerMonth ?? 0;
|
|
129
|
+
const includedCpuMs = (opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS) - reserved;
|
|
130
|
+
if (!(reserved >= 0) || !(includedCpuMs > 0)) {
|
|
131
|
+
throw new Error(`deriveCpuBudgetMs: reservedCpuMsPerMonth (${reserved}) must be ≥ 0 and leave some allowance`);
|
|
132
|
+
}
|
|
88
133
|
const requestsPerMonth = opts.requestsPerMonth ??
|
|
89
134
|
(opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
|
|
90
135
|
if (!(requestsPerMonth > 0)) {
|
|
@@ -113,6 +158,9 @@ export function monthlyOverageUsd(opts) {
|
|
|
113
158
|
*
|
|
114
159
|
* 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
|
|
115
160
|
* derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
|
|
161
|
+
*
|
|
162
|
+
* Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
|
|
163
|
+
* has the numbers (16.9 ms per app request after the edge gate's measured spend).
|
|
116
164
|
*/
|
|
117
165
|
export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
|
|
118
166
|
export function isExemption(b) {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* alternative is a number that gets quoted as a billing fact.
|
|
34
34
|
*/
|
|
35
35
|
export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert.js';
|
|
36
|
-
export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
36
|
+
export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
37
37
|
export { type CpuBudgetOpts, cpuBudget } from './cpuBudget.js';
|
|
38
38
|
export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
|
|
39
39
|
export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder.js';
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* alternative is a number that gets quoted as a billing fact.
|
|
34
34
|
*/
|
|
35
35
|
export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert.js';
|
|
36
|
-
export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
36
|
+
export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
|
|
37
37
|
export { cpuBudget } from './cpuBudget.js';
|
|
38
38
|
export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
|
|
39
39
|
export { createCpuRecorder, percentile, } from './recorder.js';
|
|
@@ -18,7 +18,17 @@ export interface MetricRow {
|
|
|
18
18
|
durationMs: number | null;
|
|
19
19
|
bytesOut: number | null;
|
|
20
20
|
userId: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Where the request came from — a PLACE, never an address (4.23.0). Filled by
|
|
23
|
+
* `requestLogger`'s `locate` hook; absent/null when nothing could place it. There is
|
|
24
|
+
* deliberately no `ip` field: the raw address is never stored, anywhere.
|
|
25
|
+
*/
|
|
26
|
+
country?: string | null;
|
|
27
|
+
region?: string | null;
|
|
28
|
+
city?: string | null;
|
|
21
29
|
}
|
|
30
|
+
/** The optional place columns, in insert order. Each is written only when the table HAS it. */
|
|
31
|
+
export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
|
|
22
32
|
export interface MetricsBufferOptions {
|
|
23
33
|
db: Database;
|
|
24
34
|
/** Flush cadence in ms. Default: 1500. */
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
/** The optional place columns, in insert order. Each is written only when the table HAS it. */
|
|
2
|
+
export const PLACE_COLUMNS = ['country', 'region', 'city'];
|
|
1
3
|
export function createMetricsBuffer(opts) {
|
|
2
4
|
const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
|
|
3
5
|
let buffer = [];
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
// 🔴 The place columns are written only where the table has them (4.23.0). An app that
|
|
7
|
+
// created `request_metrics` with its own DDL before they existed keeps working unchanged —
|
|
8
|
+
// a fixed column list would make `prepare` throw on its first boot after the bump.
|
|
9
|
+
const present = new Set(db.query('PRAGMA table_info(request_metrics)').all().map((c) => c.name));
|
|
10
|
+
const place = PLACE_COLUMNS.filter((c) => present.has(c));
|
|
11
|
+
const columns = ['id', 'ts', 'method', 'route', 'status', 'duration_ms', 'bytes_out', 'user_id', ...place];
|
|
12
|
+
const insert = db.prepare(`INSERT INTO request_metrics (${columns.join(', ')})
|
|
13
|
+
VALUES (${columns.map(() => '?').join(', ')})`);
|
|
6
14
|
const insertMany = db.transaction((rows) => {
|
|
7
15
|
for (const r of rows) {
|
|
8
|
-
insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId);
|
|
16
|
+
insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId, ...place.map((c) => r[c] ?? null));
|
|
9
17
|
}
|
|
10
18
|
});
|
|
11
19
|
const flush = () => {
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
/** A place. `country` is an ISO-3166 alpha-2 code as the edge spells it (`US`, `IE`, `T1` for Tor). */
|
|
26
|
+
export interface RequestPlace {
|
|
27
|
+
country: string;
|
|
28
|
+
region?: string | null;
|
|
29
|
+
city?: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
|
|
32
|
+
export type RequestLocator = (request: Request) => RequestPlace | null;
|
|
33
|
+
/** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
|
|
34
|
+
export declare const PLACE_FIELD_MAX_CHARS = 96;
|
|
35
|
+
/**
|
|
36
|
+
* The three columns a row stores, from whatever a locator returned. Copies ONLY
|
|
37
|
+
* `country/region/city` — so a locator that returns extra fields (an `ip`, say) cannot get them
|
|
38
|
+
* written — and treats an empty or unknown country as no place at all.
|
|
39
|
+
*/
|
|
40
|
+
export declare function normalizePlace(place: RequestPlace | null | undefined): {
|
|
41
|
+
country: string | null;
|
|
42
|
+
region: string | null;
|
|
43
|
+
city: string | null;
|
|
44
|
+
};
|
|
45
|
+
/** The edge's own answer: `request.cf` on a Worker, the `cf-*` headers behind the tunnel, else `null`. */
|
|
46
|
+
export declare const locateFromCloudflare: RequestLocator;
|
|
47
|
+
/** Run a locator the way the logger must: a throw is an unplaced request, never a failed one. */
|
|
48
|
+
export declare function placeOf(locate: RequestLocator, request: Request): ReturnType<typeof normalizePlace>;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
|
|
26
|
+
export const PLACE_FIELD_MAX_CHARS = 96;
|
|
27
|
+
/** Cloudflare's "no country could be determined" code — an unknown, not a place. */
|
|
28
|
+
const UNKNOWN_COUNTRY = 'XX';
|
|
29
|
+
const clean = (value) => {
|
|
30
|
+
if (typeof value !== 'string')
|
|
31
|
+
return null;
|
|
32
|
+
const trimmed = value.trim().slice(0, PLACE_FIELD_MAX_CHARS);
|
|
33
|
+
return trimmed === '' ? null : trimmed;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The three columns a row stores, from whatever a locator returned. Copies ONLY
|
|
37
|
+
* `country/region/city` — so a locator that returns extra fields (an `ip`, say) cannot get them
|
|
38
|
+
* written — and treats an empty or unknown country as no place at all.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizePlace(place) {
|
|
41
|
+
const country = clean(place?.country);
|
|
42
|
+
if (!country || country.toUpperCase() === UNKNOWN_COUNTRY)
|
|
43
|
+
return { country: null, region: null, city: null };
|
|
44
|
+
return { country, region: clean(place?.region), city: clean(place?.city) };
|
|
45
|
+
}
|
|
46
|
+
/** Header values arrive percent-encoded when the edge had to (`cf-ipcity: S%C3%A3o%20Paulo`). */
|
|
47
|
+
const header = (request, name) => {
|
|
48
|
+
const raw = request.headers.get(name);
|
|
49
|
+
if (raw == null)
|
|
50
|
+
return null;
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/** The edge's own answer: `request.cf` on a Worker, the `cf-*` headers behind the tunnel, else `null`. */
|
|
59
|
+
export const locateFromCloudflare = (request) => {
|
|
60
|
+
const cf = request.cf;
|
|
61
|
+
if (cf && typeof cf.country === 'string') {
|
|
62
|
+
return { country: cf.country, region: clean(cf.region), city: clean(cf.city) };
|
|
63
|
+
}
|
|
64
|
+
const country = header(request, 'cf-ipcountry');
|
|
65
|
+
if (!country)
|
|
66
|
+
return null;
|
|
67
|
+
return { country, region: header(request, 'cf-region'), city: header(request, 'cf-ipcity') };
|
|
68
|
+
};
|
|
69
|
+
/** Run a locator the way the logger must: a throw is an unplaced request, never a failed one. */
|
|
70
|
+
export function placeOf(locate, request) {
|
|
71
|
+
try {
|
|
72
|
+
return normalizePlace(locate(request));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return normalizePlace(null);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -91,8 +91,13 @@ export interface TelemetrySinkOptions {
|
|
|
91
91
|
* index1 = route, truncated to 96 bytes — the sampling key, so sampling is per route
|
|
92
92
|
* blob1 = method blob2 = route (untruncated) blob3 = userId ('' when anonymous)
|
|
93
93
|
* double1 = status double2 = durationMs double3 = bytesOut
|
|
94
|
+
* blob4 = country blob5 = region blob6 = city (v2, 4.23.0)
|
|
94
95
|
* ```
|
|
95
96
|
*
|
|
97
|
+
* v2 APPENDED blob4–6: where the request came from, as a place and never an address
|
|
98
|
+
* (`./requestPlace.ts`). `''` when unplaced, like `userId`. A v1 query never reads past blob3,
|
|
99
|
+
* so it is unaffected; a query written against v2 reads `''` on points written before it.
|
|
100
|
+
*
|
|
96
101
|
* `ts` is deliberately absent: Analytics Engine stamps its own `timestamp`
|
|
97
102
|
* column, so sending ours would store the same instant twice.
|
|
98
103
|
*
|
|
@@ -100,7 +105,7 @@ export interface TelemetrySinkOptions {
|
|
|
100
105
|
* doubles array cannot hold a null and `0` is a real value for every one of them
|
|
101
106
|
* (a 0-byte 204, notably). Read it as `NULL`, not as a measurement.
|
|
102
107
|
*/
|
|
103
|
-
export declare const TELEMETRY_POINT_VERSION =
|
|
108
|
+
export declare const TELEMETRY_POINT_VERSION = 2;
|
|
104
109
|
/** The documented layout above, as data. Exported so the spec asserts the wire format itself. */
|
|
105
110
|
export declare function toDataPoint(event: TelemetryEvent): {
|
|
106
111
|
indexes: string[];
|
|
@@ -8,8 +8,13 @@ import { createMetricsBuffer } from './metricsBuffer.js';
|
|
|
8
8
|
* index1 = route, truncated to 96 bytes — the sampling key, so sampling is per route
|
|
9
9
|
* blob1 = method blob2 = route (untruncated) blob3 = userId ('' when anonymous)
|
|
10
10
|
* double1 = status double2 = durationMs double3 = bytesOut
|
|
11
|
+
* blob4 = country blob5 = region blob6 = city (v2, 4.23.0)
|
|
11
12
|
* ```
|
|
12
13
|
*
|
|
14
|
+
* v2 APPENDED blob4–6: where the request came from, as a place and never an address
|
|
15
|
+
* (`./requestPlace.ts`). `''` when unplaced, like `userId`. A v1 query never reads past blob3,
|
|
16
|
+
* so it is unaffected; a query written against v2 reads `''` on points written before it.
|
|
17
|
+
*
|
|
13
18
|
* `ts` is deliberately absent: Analytics Engine stamps its own `timestamp`
|
|
14
19
|
* column, so sending ours would store the same instant twice.
|
|
15
20
|
*
|
|
@@ -17,7 +22,7 @@ import { createMetricsBuffer } from './metricsBuffer.js';
|
|
|
17
22
|
* doubles array cannot hold a null and `0` is a real value for every one of them
|
|
18
23
|
* (a 0-byte 204, notably). Read it as `NULL`, not as a measurement.
|
|
19
24
|
*/
|
|
20
|
-
export const TELEMETRY_POINT_VERSION =
|
|
25
|
+
export const TELEMETRY_POINT_VERSION = 2;
|
|
21
26
|
const INDEX_MAX_BYTES = 96;
|
|
22
27
|
/**
|
|
23
28
|
* Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point —
|
|
@@ -38,7 +43,14 @@ export function toDataPoint(event) {
|
|
|
38
43
|
const route = event.route ?? '';
|
|
39
44
|
return {
|
|
40
45
|
indexes: [truncateUtf8(route, INDEX_MAX_BYTES)],
|
|
41
|
-
blobs: [
|
|
46
|
+
blobs: [
|
|
47
|
+
event.method ?? '',
|
|
48
|
+
route,
|
|
49
|
+
event.userId ?? '',
|
|
50
|
+
event.country ?? '',
|
|
51
|
+
event.region ?? '',
|
|
52
|
+
event.city ?? '',
|
|
53
|
+
],
|
|
42
54
|
doubles: [num(event.status), num(event.durationMs), num(event.bytesOut)],
|
|
43
55
|
};
|
|
44
56
|
}
|
|
@@ -3,6 +3,7 @@ import type { MiddlewareHandler } from 'hono';
|
|
|
3
3
|
import type { CursedbeltEnv } from '../context.js';
|
|
4
4
|
import type { MetricsBuffer } from '../metrics/metricsBuffer.js';
|
|
5
5
|
import { type AnalyticsEngineDataset, type TelemetrySink } from '../metrics/telemetrySink.js';
|
|
6
|
+
import { type RequestLocator } from '../metrics/requestPlace.js';
|
|
6
7
|
/**
|
|
7
8
|
* Structured request/response logging. After each request it records one
|
|
8
9
|
* telemetry event through the {@link TelemetrySink} (off the hot path) and, for
|
|
@@ -33,6 +34,13 @@ export interface RequestLoggerOpts {
|
|
|
33
34
|
sink?: TelemetrySink;
|
|
34
35
|
/** @deprecated Use {@link RequestLoggerOpts.sink} — a `MetricsBuffer` is one. Kept for callers that predate the sink. */
|
|
35
36
|
buffer?: MetricsBuffer;
|
|
37
|
+
/**
|
|
38
|
+
* Where the request came from — returns a PLACE (`{ country, region?, city? }`) or `null`,
|
|
39
|
+
* never an address; the raw IP is not stored anywhere (`../metrics/requestPlace.ts`).
|
|
40
|
+
* Default: {@link locateFromCloudflare} — `request.cf` on a Worker, the `cf-ipcountry`
|
|
41
|
+
* header behind the tunnel. A throw is caught and the request recorded unplaced.
|
|
42
|
+
*/
|
|
43
|
+
locate?: RequestLocator;
|
|
36
44
|
/** Duration (ms) at/above which a 2xx request also logs an `event_logs` row. Default: 2000. */
|
|
37
45
|
slowMs?: number;
|
|
38
46
|
/** Also write `event_logs` rows for status ≥ 400. Default: true. */
|
|
@@ -2,6 +2,7 @@ import { HTTPException } from 'hono/http-exception';
|
|
|
2
2
|
import { isApiError } from '../errors.js';
|
|
3
3
|
import { normalizeRoute } from '../metrics/normalizeRoute.js';
|
|
4
4
|
import { createTelemetrySink, } from '../metrics/telemetrySink.js';
|
|
5
|
+
import { locateFromCloudflare, placeOf } from '../metrics/requestPlace.js';
|
|
5
6
|
function statusOfThrown(err) {
|
|
6
7
|
if (isApiError(err))
|
|
7
8
|
return err.status;
|
|
@@ -20,6 +21,7 @@ export function requestLogger(db, opts = {}) {
|
|
|
20
21
|
const sink = opts.sink ?? opts.buffer ?? createTelemetrySink({ db, analytics: opts.analytics ?? null });
|
|
21
22
|
const slowMs = opts.slowMs ?? 2000;
|
|
22
23
|
const logErrors = opts.logErrors ?? true;
|
|
24
|
+
const locate = opts.locate ?? locateFromCloudflare;
|
|
23
25
|
const insertEvent = db
|
|
24
26
|
? db.prepare(`INSERT INTO event_logs
|
|
25
27
|
(id, correlation_id, event_name, type, message, details, duration_ms, user_id, created_at)
|
|
@@ -42,7 +44,8 @@ export function requestLogger(db, opts = {}) {
|
|
|
42
44
|
const lenHeader = didThrow ? null : c.res.headers.get('content-length');
|
|
43
45
|
const bytesOut = lenHeader ? Number(lenHeader) : null;
|
|
44
46
|
const userId = c.get('userId') ?? null;
|
|
45
|
-
|
|
47
|
+
const place = placeOf(locate, c.req.raw);
|
|
48
|
+
sink.record({ ts: Date.now(), method, route, status, durationMs, bytesOut, userId, ...place });
|
|
46
49
|
const isError = status >= 400;
|
|
47
50
|
const isSlow = durationMs >= slowMs;
|
|
48
51
|
if (insertEvent && ((logErrors && isError) || isSlow)) {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
*/
|
|
61
61
|
import { Database } from "bun:sqlite";
|
|
62
62
|
import type { MiddlewareHandler } from "hono";
|
|
63
|
+
import type { RequestLocator } from "../metrics/requestPlace.js";
|
|
63
64
|
import { type TelemetrySink } from "../metrics/telemetrySink.js";
|
|
64
65
|
/** The file station reads, inside the app's data directory. */
|
|
65
66
|
export declare const METRICS_DB = "metrics.sqlite";
|
|
@@ -80,12 +81,20 @@ export interface RequestMetrics {
|
|
|
80
81
|
/** Where an inherited corpus went at this boot, if one was found. */
|
|
81
82
|
archived: string | null;
|
|
82
83
|
}
|
|
84
|
+
export interface RequestMetricsOptions {
|
|
85
|
+
/**
|
|
86
|
+
* Place each request (`{ country, region?, city? }` or `null`) — never an address; the raw IP
|
|
87
|
+
* is not stored. Default: the edge's own answer, `locateFromCloudflare` (the `cf-ipcountry`
|
|
88
|
+
* header every tunnelled request carries). station passes its DB-IP reader here.
|
|
89
|
+
*/
|
|
90
|
+
locate?: RequestLocator;
|
|
91
|
+
}
|
|
83
92
|
/** Open the corpus in `dir` and build the logger that writes into it. */
|
|
84
|
-
export declare function openRequestMetrics(dir: string): RequestMetrics;
|
|
93
|
+
export declare function openRequestMetrics(dir: string, options?: RequestMetricsOptions): RequestMetrics;
|
|
85
94
|
/**
|
|
86
95
|
* The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
|
|
87
96
|
* is set (the launchd plist sets it) and no test runner is in charge. A dev shell
|
|
88
97
|
* records nothing rather than appending a developer's clicks to the live corpus.
|
|
89
98
|
* Never throws: a failure is one stderr line and an app that serves unmeasured.
|
|
90
99
|
*/
|
|
91
|
-
export declare function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv): RequestMetrics | null;
|
|
100
|
+
export declare function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv, options?: RequestMetricsOptions): RequestMetrics | null;
|
|
@@ -62,6 +62,7 @@ import { Database } from "bun:sqlite";
|
|
|
62
62
|
import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
|
|
63
63
|
import { join } from "node:path";
|
|
64
64
|
import { applyCcPragmas } from "cwip/sqlite";
|
|
65
|
+
import { PLACE_COLUMNS } from "../metrics/metricsBuffer.js";
|
|
65
66
|
import { createTelemetrySink } from "../metrics/telemetrySink.js";
|
|
66
67
|
import { requestLogger } from "../middleware/requestLogger.js";
|
|
67
68
|
import { runMigrations } from "../migration/runner.js";
|
|
@@ -90,6 +91,20 @@ const REQUEST_METRICS_MIGRATIONS = [
|
|
|
90
91
|
db.run("CREATE INDEX idx_request_metrics_ts ON request_metrics (ts)");
|
|
91
92
|
},
|
|
92
93
|
},
|
|
94
|
+
{
|
|
95
|
+
// 4.23.0, task 2123: where a request came from — a PLACE, never the address (no `ip`
|
|
96
|
+
// column, ever; `../metrics/requestPlace.ts`). Nullable, so every row written before
|
|
97
|
+
// this reads as unplaced. Idempotent beyond the runner's once-only record: a column
|
|
98
|
+
// already present (hand-added, or a half-applied copy of this step) is left alone.
|
|
99
|
+
name: "0002_request_place",
|
|
100
|
+
up(db) {
|
|
101
|
+
const have = new Set(db.query("PRAGMA table_info(request_metrics)").all().map((c) => c.name));
|
|
102
|
+
for (const column of PLACE_COLUMNS) {
|
|
103
|
+
if (!have.has(column))
|
|
104
|
+
db.run(`ALTER TABLE request_metrics ADD COLUMN ${column} TEXT`);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
},
|
|
93
108
|
];
|
|
94
109
|
/** The `application_id` stamped in a SQLite file's header, or null when it has no header yet. */
|
|
95
110
|
function headerApplicationId(path) {
|
|
@@ -145,7 +160,7 @@ function openMetricsDb(dir) {
|
|
|
145
160
|
return { db, archived };
|
|
146
161
|
}
|
|
147
162
|
/** Open the corpus in `dir` and build the logger that writes into it. */
|
|
148
|
-
export function openRequestMetrics(dir) {
|
|
163
|
+
export function openRequestMetrics(dir, options = {}) {
|
|
149
164
|
const { db, archived } = openMetricsDb(dir);
|
|
150
165
|
const inner = createTelemetrySink({ db });
|
|
151
166
|
// 🔴 No user id — see the header. Nulled here so no context value can reach the row.
|
|
@@ -158,7 +173,10 @@ export function openRequestMetrics(dir) {
|
|
|
158
173
|
},
|
|
159
174
|
};
|
|
160
175
|
// `null` database: the logger writes through `sink` only, and no `event_logs`.
|
|
161
|
-
const middleware = requestLogger(null, {
|
|
176
|
+
const middleware = requestLogger(null, {
|
|
177
|
+
sink,
|
|
178
|
+
...(options.locate ? { locate: options.locate } : {}),
|
|
179
|
+
});
|
|
162
180
|
return { db, sink, middleware, archived };
|
|
163
181
|
}
|
|
164
182
|
/**
|
|
@@ -167,12 +185,12 @@ export function openRequestMetrics(dir) {
|
|
|
167
185
|
* records nothing rather than appending a developer's clicks to the live corpus.
|
|
168
186
|
* Never throws: a failure is one stderr line and an app that serves unmeasured.
|
|
169
187
|
*/
|
|
170
|
-
export function openRequestMetricsFromEnv(name, env) {
|
|
188
|
+
export function openRequestMetricsFromEnv(name, env, options = {}) {
|
|
171
189
|
const dir = env.APP_DATA_DIR?.trim();
|
|
172
190
|
if (!dir || isTestRuntime(env))
|
|
173
191
|
return null;
|
|
174
192
|
try {
|
|
175
|
-
const metrics = openRequestMetrics(dir);
|
|
193
|
+
const metrics = openRequestMetrics(dir, options);
|
|
176
194
|
if (metrics.archived) {
|
|
177
195
|
console.log(`[${name}] request metrics: archived the inherited corpus to ${metrics.archived}`);
|
|
178
196
|
}
|
|
@@ -17,3 +17,4 @@
|
|
|
17
17
|
*/
|
|
18
18
|
export { type RequestLoggerOpts, requestLogger } from './middleware/requestLogger.js';
|
|
19
19
|
export { type AnalyticsEngineDataset, createTelemetrySink, TELEMETRY_POINT_VERSION, type TelemetryEvent, type TelemetrySink, type TelemetrySinkOptions, toDataPoint, } from './metrics/telemetrySink.js';
|
|
20
|
+
export { locateFromCloudflare, type RequestLocator, type RequestPlace, } from './metrics/requestPlace.js';
|
package/dist/server/telemetry.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.23.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DEFAULT_ROUTE_CPU_BUDGET_MS,
|
|
5
5
|
deriveCpuBudgetMs,
|
|
6
6
|
MEASURED_FLEET_REQUESTS_PER_DAY,
|
|
7
|
+
MEASURED_WORKER_TRAFFIC,
|
|
7
8
|
monthlyOverageUsd,
|
|
8
9
|
OWNER_PROJECTED_REQUESTS_PER_DAY,
|
|
9
10
|
resolveBudget,
|
|
@@ -64,6 +65,43 @@ describe('the derivation', () => {
|
|
|
64
65
|
});
|
|
65
66
|
});
|
|
66
67
|
|
|
68
|
+
describe('the WORKER re-derivation (2026-09-23, task 083)', () => {
|
|
69
|
+
const W = MEASURED_WORKER_TRAFFIC;
|
|
70
|
+
const perDay = W.requests / W.days;
|
|
71
|
+
const edgeGatePerMonth = (W.edgeGateCpuMs / W.days) * DAYS_PER_MONTH;
|
|
72
|
+
|
|
73
|
+
it('the naive call over the whole account says 9.1 — below the default — and that is recorded, not hidden', () => {
|
|
74
|
+
expect(perDay).toBeCloseTo(108_001, 0);
|
|
75
|
+
const naive = deriveCpuBudgetMs({ requestsPerDay: perDay });
|
|
76
|
+
expect(naive).toBeCloseTo(9.13, 2);
|
|
77
|
+
expect(naive).toBeLessThan(DEFAULT_ROUTE_CPU_BUDGET_MS);
|
|
78
|
+
// …because 99.9 % of it is the edge gate, whose mean sits far under any route budget.
|
|
79
|
+
expect(W.edgeGateRequests / W.requests).toBeGreaterThan(0.998);
|
|
80
|
+
expect(W.edgeGateCpuMs / W.edgeGateRequests).toBeLessThan(DEFAULT_ROUTE_CPU_BUDGET_MS / 4);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('🔴 the default stays under what an APP request may spend once the edge gate is set aside', () => {
|
|
84
|
+
const appAllowance = deriveCpuBudgetMs({
|
|
85
|
+
requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY,
|
|
86
|
+
reservedCpuMsPerMonth: edgeGatePerMonth,
|
|
87
|
+
});
|
|
88
|
+
expect(appAllowance).toBeCloseTo(16.9, 1);
|
|
89
|
+
expect(DEFAULT_ROUTE_CPU_BUDGET_MS).toBeLessThanOrEqual(appAllowance);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('and there is no evidence for tightening it: the account spends 30 % of its CPU allowance', () => {
|
|
93
|
+
const monthly = (W.cpuMs / W.days) * DAYS_PER_MONTH;
|
|
94
|
+
expect(monthly / WORKERS_PAID_INCLUDED_CPU_MS).toBeCloseTo(0.3, 2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('FAILURE PATH: a reservation that eats the allowance is refused, not turned into a negative budget', () => {
|
|
98
|
+
expect(() => deriveCpuBudgetMs({ reservedCpuMsPerMonth: WORKERS_PAID_INCLUDED_CPU_MS })).toThrow(
|
|
99
|
+
/reservedCpuMsPerMonth/,
|
|
100
|
+
);
|
|
101
|
+
expect(() => deriveCpuBudgetMs({ reservedCpuMsPerMonth: -1 })).toThrow(/reservedCpuMsPerMonth/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
67
105
|
describe('what going over actually costs', () => {
|
|
68
106
|
it('prices the overage — 3× the CPU budget at this traffic is about $1.20/month', () => {
|
|
69
107
|
const requestsPerMonth = OWNER_PROJECTED_REQUESTS_PER_DAY * DAYS_PER_MONTH;
|