@ultimat3/core 16.0.0 → 17.0.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/CLAUDE.md +32 -1
- package/README.md +9 -4
- package/package.json +1 -1
- package/src/backoff.ts +19 -7
- package/src/context.ts +35 -3
- package/src/error-codes.ts +3 -0
- package/src/error-retry.ts +14 -2
- package/src/finite-option.ts +54 -0
- package/src/ids.ts +15 -3
- package/src/image/pipeline.ts +23 -1
- package/src/index.ts +9 -1
- package/src/intl-cache.ts +42 -1
- package/src/lifecycle.ts +12 -1
- package/src/metric-names.ts +68 -0
- package/src/metrics-text.ts +6 -0
- package/src/metrics.ts +21 -23
- package/src/otlp-metric-exporter.ts +2 -1
- package/src/otlp-span-exporter.ts +9 -4
- package/src/otlp.ts +24 -0
- package/src/read-capped.ts +24 -6
- package/src/sampler.ts +40 -4
- package/src/telemetry.ts +5 -2
package/CLAUDE.md
CHANGED
|
@@ -125,7 +125,7 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
125
125
|
| the five above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | `@ultimat3/action` and `@ultimat3/query` both project a typed client and are both tier 3, so neither could import the other: it shipped as a byte-identical 288-line + 85-line copy in each, policed by a `client-twin.test.ts` in both. Both packages re-export these names, so their public surface is unchanged. Declares NO code of its own — `X_SUPERSEDED`, `X_TIMEOUT` and `X_FLIGHT_GATE_OVERLOADED` are already here |
|
|
126
126
|
| is this `unknown` a keyed record? | `json-object.ts` | `isJsonObject`, which was the same three terms in both those packages' `stable.ts`. A `Date` and a class instance PASS: it narrows a shape, it does not certify provenance |
|
|
127
127
|
| a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
|
|
128
|
-
| an `Intl` formatter cache | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `MAX_CACHED_FORMATTERS`) | a locale and a zone arrive from a request header, so the key must be canonical AND the cache bounded — never a second copy of
|
|
128
|
+
| an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`) | a locale and a zone arrive from a request header, so the tag must be REFUSED when it is not a tag (`X_LOCALE_INVALID`), the key must be canonical AND the cache bounded — never a second copy of any of the three |
|
|
129
129
|
| the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
|
|
130
130
|
|
|
131
131
|
`installSecrets()` is the ONLY path from `secrets.enc.json` to an app value, and it lands in
|
|
@@ -144,6 +144,15 @@ and live in one file for that reason: a canonical key bounds nothing (an unknown
|
|
|
144
144
|
value survives canonicalization as a distinct string) and the cap alone lets one locale evict
|
|
145
145
|
itself under three spellings. Never build an `Intl` formatter on a caller string without both.
|
|
146
146
|
|
|
147
|
+
`assertLocale` and `X_LOCALE_INVALID` moved here from `@ultimat3/time` in 16.x, for the third
|
|
148
|
+
time the same argument was made: `@ultimat3/money` handed a malformed `Accept-Language` tag
|
|
149
|
+
straight to `Intl.NumberFormat` at four entry points and let a bare `RangeError` out, and copying
|
|
150
|
+
time's screen down would have been a second answer to one question. Validating and keying are ONE
|
|
151
|
+
step — `getCanonicalLocales` runs exactly the check `supportedLocalesOf` throws on and hands back
|
|
152
|
+
the spelling the cache keys on — which is why the screen lives in this file and not beside it. A
|
|
153
|
+
code has one declaration: `X_LOCALE_INVALID` left `TIME_ERROR_CODES` in the same change, or
|
|
154
|
+
`registerErrorCodes` would raise `X_ERROR_CODE_DUPLICATE` the moment both packages loaded.
|
|
155
|
+
|
|
147
156
|
`secrets-errors.ts` registers its seven codes through `registerErrorCodes()` rather than joining
|
|
148
157
|
`CORE_CODE_TITLES` — the codes and the module that throws them ship together, and `registerErrorCodes`
|
|
149
158
|
is the one mechanism that raises `X_ERROR_CODE_DUPLICATE` if anything else claims one. Consequence
|
|
@@ -218,6 +227,28 @@ the layer imports anything but this package, nothing runs at import time, and ev
|
|
|
218
227
|
non-determinism — the roll, the sleep, the clock, the timer — is injected, because a schedule
|
|
219
228
|
provable only by waiting is a schedule no test pins.
|
|
220
229
|
|
|
230
|
+
**`backoffDelay` REFUSES a non-finite bound, `As of 2026-08-26`, and that is a behaviour change.**
|
|
231
|
+
It used to answer `0`, on the argument that 0 is "wrong loudly". It is not: measured,
|
|
232
|
+
`retry({ attempts: 5, max: NaN })` slept `[0, 0, 0, 0]` and `factor: NaN` slept `[1000, 0, 0, 0]` —
|
|
233
|
+
a retry loop with no wait at all, which is the failure backoff exists to prevent, on the tree's ONE
|
|
234
|
+
curve (`bun run flight-copies`), reaching every retry in the framework. `attempt`, `base`, `max`
|
|
235
|
+
and `factor` each go through `finiteOption` before the clamps, because `Math.max`, `Math.min` and
|
|
236
|
+
`Math.trunc` propagate rather than validate and `Math.min(raw, Infinity)` is `Infinity`. The
|
|
237
|
+
`return 0` below them stays and is still reachable, by exactly one route: `factor ** (step - 1)`
|
|
238
|
+
overflows around attempt 1030 and `0 * Infinity` is `NaN` — a zero base is a caller asking for no
|
|
239
|
+
wait, which is the answer it gets.
|
|
240
|
+
|
|
241
|
+
**`finiteOption` / `finiteCount` (`finite-option.ts`) are the framework's ONE screen for a numeric
|
|
242
|
+
option, `As of 2026-08-26`.** They landed here because `@ultimat3/jobs`, `@ultimat3/realtime` and
|
|
243
|
+
`@ultimat3/query` each held a byte-identical copy; all three were deleted and now import these.
|
|
244
|
+
`finiteCount(subject, option, value, min)` takes `min: 0 | 1` as a DEFAULT PARAMETER rather than
|
|
245
|
+
shipping a third `finitePositive`, because only the caller knows what zero means —
|
|
246
|
+
`requestTimeoutMs: 0` is "no deadline", a pool `max: 0` is a pool nothing can run on.
|
|
247
|
+
`bun run finite-bounds` is the ratchet, and it recognises a repair by the SHAPE OF THE CALL: a
|
|
248
|
+
package screen that does not carry `Finite` in its name is invisible to it, which is why
|
|
249
|
+
`assertFiniteImageQuality`, `assertFiniteOtlpBound` and their siblings in five other packages are
|
|
250
|
+
spelled that way.
|
|
251
|
+
|
|
221
252
|
Two rules that are not preferences. `backoffDelay` clamps to `max` **before** jitter: capping after
|
|
222
253
|
turns `full` into a distribution whose upper half is a single value at `max`, which is the
|
|
223
254
|
correlation jitter exists to remove. `createFlightGate` **hands** its slot to a waiter rather than
|
package/README.md
CHANGED
|
@@ -316,7 +316,9 @@ nothing and still reports healthy.
|
|
|
316
316
|
- **Sampling is honoured, not just propagated.** `startSpan` takes the parent's bit when there is
|
|
317
317
|
one, else asks the `Sampler`; `span.end()` exports nothing when the bit is 0. The default reads
|
|
318
318
|
`OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` at the first span, and
|
|
319
|
-
`configureTelemetry({ sampler })` replaces it.
|
|
319
|
+
`configureTelemetry({ sampler })` replaces it. `shouldSample` is handed the TRACE ID of the span
|
|
320
|
+
being created, so `traceidratio` decides once per trace by hashing it — a roll per span left one
|
|
321
|
+
trace half exported, which is orphan children under a root the collector never saw.
|
|
320
322
|
- **The OTLP exporters ship**, speaking OTLP/HTTP JSON, no dependency:
|
|
321
323
|
`otlpSpanExporter({ endpoint })` (batched, with `flush()` / `shutdown()`) and
|
|
322
324
|
`otlpMetricExporter({ endpoint })`. Both default to `OTEL_EXPORTER_OTLP_ENDPOINT`;
|
|
@@ -457,13 +459,16 @@ never a silently wrong page.
|
|
|
457
459
|
## One bounded cache for every `Intl` formatter
|
|
458
460
|
|
|
459
461
|
```ts
|
|
460
|
-
import {
|
|
462
|
+
import { assertLocale, cachedFormatter } from '@ultimat3/core';
|
|
461
463
|
|
|
462
464
|
const cache = new Map<string, Intl.NumberFormat>();
|
|
463
465
|
|
|
464
466
|
export function euroFormatter(locale: string): Intl.NumberFormat {
|
|
465
|
-
// `EN-us` and `en-latn-us` collapse to one key,
|
|
466
|
-
|
|
467
|
+
// Validates AND canonicalizes: `EN-us` and `en-latn-us` collapse to one key, and a tag `Intl`
|
|
468
|
+
// cannot parse is `X_LOCALE_INVALID` here rather than a bare `RangeError` out of the constructor
|
|
469
|
+
// below. `canonicalLocale(locale) ?? locale` is the same call with the refusal deleted — it is
|
|
470
|
+
// what `@ultimat3/money` did, and an `Accept-Language` value of `en_US` took the request down.
|
|
471
|
+
const tag = assertLocale(locale);
|
|
467
472
|
return cachedFormatter(
|
|
468
473
|
cache,
|
|
469
474
|
`${tag}|EUR`,
|
package/package.json
CHANGED
package/src/backoff.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// (equal jitter), `@ultimat3/ai` (full jitter, `Math.random` inline and so untestable),
|
|
4
4
|
// `@ultimat3/realtime` (full jitter, 0-based attempt) and `@ultimat3/db` (no backoff at all).
|
|
5
5
|
|
|
6
|
+
import { finiteOption } from './finite-option';
|
|
7
|
+
|
|
6
8
|
export type BackoffCurve = 'exponential' | 'linear' | 'fixed';
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -12,6 +14,9 @@ export type BackoffCurve = 'exponential' | 'linear' | 'fixed';
|
|
|
12
14
|
*/
|
|
13
15
|
export type JitterMode = 'full' | 'equal' | 'none';
|
|
14
16
|
|
|
17
|
+
/** Named in every refusal below, so a reader knows which call built the schedule. */
|
|
18
|
+
const SUBJECT = 'backoffDelay';
|
|
19
|
+
|
|
15
20
|
/** Injected everywhere. A delay only provable by observing a range is a delay no test pins. */
|
|
16
21
|
export type Random = () => number;
|
|
17
22
|
|
|
@@ -38,19 +43,26 @@ export interface BackoffOptions {
|
|
|
38
43
|
* jitter exists to remove.
|
|
39
44
|
*/
|
|
40
45
|
export function backoffDelay(options: BackoffOptions): number {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// Every bound is REFUSED before it is clamped, because the clamps are not validators: measured,
|
|
47
|
+
// `retry({ attempts: 5, max: NaN })` slept `[0, 0, 0, 0]` and `factor: NaN` slept
|
|
48
|
+
// `[1000, 0, 0, 0]` — `Math.max`, `Math.min` and `Math.trunc` each propagate it,
|
|
49
|
+
// `Math.min(raw, Infinity)` is `Infinity`, and the `return 0` below then turns the whole
|
|
50
|
+
// schedule into a spin.
|
|
51
|
+
const step = Math.max(1, Math.trunc(finiteOption(SUBJECT, 'attempt', options.attempt)));
|
|
52
|
+
const base = Math.max(0, finiteOption(SUBJECT, 'base', options.base));
|
|
53
|
+
const max = finiteOption(SUBJECT, 'max', options.max);
|
|
54
|
+
const factor = finiteOption(SUBJECT, 'factor', options.factor ?? 2);
|
|
44
55
|
|
|
45
56
|
let raw: number;
|
|
46
57
|
if (options.curve === 'fixed') raw = base;
|
|
47
58
|
else if (options.curve === 'linear') raw = base * step;
|
|
48
59
|
else raw = base * factor ** (step - 1);
|
|
49
60
|
|
|
50
|
-
const capped = Math.max(0, Math.min(raw,
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
61
|
+
const capped = Math.max(0, Math.min(raw, max));
|
|
62
|
+
// Still reachable with four finite inputs, and only one way: `factor ** (step - 1)` overflows to
|
|
63
|
+
// `Infinity` around attempt 1030, and `0 * Infinity` is `NaN`. A zero base is a caller asking
|
|
64
|
+
// for no wait, so 0 is the answer it asked for — it is no longer the answer an unvalidated
|
|
65
|
+
// config value gets, which is what this line used to be.
|
|
54
66
|
if (!Number.isFinite(capped)) return 0;
|
|
55
67
|
|
|
56
68
|
const roll = options.random ?? Math.random;
|
package/src/context.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { type Actor, anonymousActor } from './actor';
|
|
|
34
34
|
import { asyncContext } from './async-context';
|
|
35
35
|
import { type Clock, systemClock } from './clock';
|
|
36
36
|
import { UltimateError } from './errors';
|
|
37
|
+
import { finiteOption } from './finite-option';
|
|
37
38
|
import { traceId as newTraceId, uuid } from './ids';
|
|
38
39
|
import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
|
|
39
40
|
import { type Role, resolveRole } from './roles';
|
|
@@ -173,7 +174,7 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
173
174
|
now: () => clock.now(),
|
|
174
175
|
logger: base.child({ requestId, traceId: trace }),
|
|
175
176
|
signal: init.signal ?? neverAborted,
|
|
176
|
-
deadlineAt: init.deadlineAt
|
|
177
|
+
deadlineAt: screenDeadline(init.deadlineAt),
|
|
177
178
|
};
|
|
178
179
|
// A registered service (`defineService`) closes over the ctx it is built for — actor, clock,
|
|
179
180
|
// tz — so it has to run HERE, against this exact call's fields, rather than once at boot and
|
|
@@ -231,6 +232,35 @@ export function hasContext(): boolean {
|
|
|
231
232
|
return tryUseContext() !== undefined;
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The tighter of two instants, where absent means "no bound" — the bounded form
|
|
237
|
+
* `lifecycle.ts`'s drain budget uses, over two optional values instead of one. Returns
|
|
238
|
+
* `undefined` rather than `null` because that is what `CtxInit.deadlineAt` spells.
|
|
239
|
+
*/
|
|
240
|
+
function earliest(left: number | undefined, right: number | null | undefined): number | undefined {
|
|
241
|
+
if (left === undefined) return right ?? undefined;
|
|
242
|
+
if (right === undefined || right === null) return left;
|
|
243
|
+
return Math.min(left, right);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The one screen over `deadlineAt`, at both boundaries that can set it. A deadline is an instant,
|
|
248
|
+
* not a count, so `finiteOption` rather than `finiteCount` — a clock epoch is legitimately large
|
|
249
|
+
* and a test clock's is legitimately small.
|
|
250
|
+
*
|
|
251
|
+
* WHY IT IS REFUSED AND NOT CLAMPED, in this file specifically: `Math.min` PROPAGATES `NaN`, so one
|
|
252
|
+
* non-finite side poisons the child's deadline as well as its own. `remainingBudgetMs` then asks
|
|
253
|
+
* `left >= 1` of a `NaN`, gets `false`, and answers `undefined` — the same answer it gives for "no
|
|
254
|
+
* deadline at all". So the `x-request-timeout-ms` header is silently omitted and the next hop falls
|
|
255
|
+
* back to its OWN configured budget, which `request-budget.ts`'s own header calls "the exact
|
|
256
|
+
* failure this header exists to prevent". A deadline that quietly stops bounding anything is the
|
|
257
|
+
* dangerous direction, and it is the direction `??` cannot see, because `NaN` is not nullish.
|
|
258
|
+
*/
|
|
259
|
+
function screenDeadline(value: number | null | undefined): number | null {
|
|
260
|
+
if (value === undefined || value === null) return null;
|
|
261
|
+
return finiteOption('the request context', 'deadlineAt', value);
|
|
262
|
+
}
|
|
263
|
+
|
|
234
264
|
/**
|
|
235
265
|
* Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
|
|
236
266
|
* `requestId` is deliberately not patchable: one request, one id.
|
|
@@ -256,8 +286,10 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
|
256
286
|
logger: patch.logger ?? parent.logger,
|
|
257
287
|
// One request, one budget: a child scope inherits the deadline for the same reason it
|
|
258
288
|
// inherits `requestId`. A patch may SHORTEN it (a step with its own budget); nothing here
|
|
259
|
-
// lengthens it, because the socket the parent is answering does not move.
|
|
260
|
-
|
|
289
|
+
// lengthens it, because the socket the parent is answering does not move. `??` alone said
|
|
290
|
+
// that and did not do it — a patched hour replaced a parent's second outright, and
|
|
291
|
+
// `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
|
|
292
|
+
deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
|
|
261
293
|
signal: patch.signal ?? parent.signal,
|
|
262
294
|
services: { ...carried, ...(patch.services ?? {}) },
|
|
263
295
|
});
|
package/src/error-codes.ts
CHANGED
|
@@ -52,6 +52,9 @@ const CORE_CODE_TITLES = {
|
|
|
52
52
|
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
53
53
|
X_INTERNAL: 'unexpected internal framework error',
|
|
54
54
|
X_INVARIANT: 'invariant violated',
|
|
55
|
+
// Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
|
|
56
|
+
// needs the same screen and tier 1 may not import sideways. One code, one declaration.
|
|
57
|
+
X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
|
|
55
58
|
X_METRIC_CARDINALITY:
|
|
56
59
|
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
57
60
|
X_METRIC_NAME_INVALID:
|
package/src/error-retry.ts
CHANGED
|
@@ -106,9 +106,21 @@ export function registerErrorRetry(retries: Readonly<Record<string, ErrorRetry>>
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Test seam. Production registers once at boot and never unregisters.
|
|
111
|
+
*
|
|
112
|
+
* `restore` is not a nicety: this map is PROCESS-WIDE and every package fills it at module
|
|
113
|
+
* evaluation, so a bare `clear()` in one file's `afterEach` deletes registrations that file never
|
|
114
|
+
* made. Bun runs one process for the whole suite and `scripts/test-setup.ts` imports packages
|
|
115
|
+
* before any test runs, so their module bodies never run again to repair it — measured,
|
|
116
|
+
* `@ultimat3/policy`'s codes read as unclassified in every file loaded after core's tests, and
|
|
117
|
+
* `@ultimat3/db`, `@ultimat3/ai` and `@ultimat3/jobs` each carry a `beforeEach` that re-registers
|
|
118
|
+
* their own table for exactly this reason. Pass `registeredErrorRetry()` captured at module scope
|
|
119
|
+
* and the wipe is scoped to what the test added.
|
|
120
|
+
*/
|
|
121
|
+
export function resetErrorRetry(restore: Readonly<Record<string, ErrorRetry>> = {}): void {
|
|
111
122
|
REGISTERED.clear();
|
|
123
|
+
for (const [code, retry] of Object.entries(restore)) REGISTERED.set(code, retry);
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
/**
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Single responsibility: the one refusal the framework makes for a numeric option that is not a
|
|
2
|
+
// number. Tier 0 because twenty-one packages need it — `jobs`, `realtime` and `query` each carried
|
|
3
|
+
// a byte-identical copy, the shape three backoff curves and three SQL escapes already took.
|
|
4
|
+
// `bun run finite-bounds` is the ratchet; this is the repair it recognises.
|
|
5
|
+
|
|
6
|
+
import { assert } from './assert';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The option, when it is a real number. Returns it so a call site stays one expression:
|
|
10
|
+
* `this.#capacity = finiteOption('ChangeBuffer', 'capacity', options.capacity ?? 1024)`.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT TAKES A FUNCTION AT ALL, since every part of the job looks already done by an operator:
|
|
13
|
+
* `??` guards NULLISH and `NaN` is not nullish, so `Number(process.env.X)` on an unset variable, a
|
|
14
|
+
* `parseInt` of a typo and a JSON `null`-turned-`NaN` all walk straight past the default. And
|
|
15
|
+
* `Math.max` / `Math.min` / `Math.floor` PROPAGATE it rather than validating, so none of the three
|
|
16
|
+
* is a screen either — `Math.min(raw, Infinity)` is `Infinity`. What the value then bounds decides
|
|
17
|
+
* WHICH failure you get, and all four have shipped: a comparison that reads false forever, an
|
|
18
|
+
* `Array.from({ length: NaN })` that is `[]`, a `setTimeout(fn, NaN)` that is `setTimeout(fn, 0)`,
|
|
19
|
+
* and a loop that never terminates. Several packages' comments point here for this argument.
|
|
20
|
+
*/
|
|
21
|
+
export function finiteOption(subject: string, option: string, value: number): number {
|
|
22
|
+
assert(
|
|
23
|
+
Number.isFinite(value),
|
|
24
|
+
`${subject} ${option} is ${String(value)}, so every comparison against it is false and the bound it sets does not exist`,
|
|
25
|
+
`pass a finite ${option} to ${subject}, or omit it and take the default — Math.max/Math.min/Math.floor do not validate it, they propagate it`,
|
|
26
|
+
);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The same rule where the option counts THINGS — rows, bytes, entries, milliseconds, slots — and a
|
|
32
|
+
* fraction is as wrong as a `NaN`: `Array.from({ length: 2.5 })` throws, a `slice` truncates, and
|
|
33
|
+
* above 2^53 a double cannot name its own successor, so a "count" up there is already rounded.
|
|
34
|
+
* `Number.isSafeInteger`, which is what `@ultimat3/schema` demands of an integer at the wire.
|
|
35
|
+
*
|
|
36
|
+
* `min` is the caller's because only the caller knows what zero MEANS: `requestTimeoutMs: 0` is
|
|
37
|
+
* "no deadline" and `maxInflight: 0` is "never shed", both decisions the code reads, while
|
|
38
|
+
* `max: 0` connections is a pool nothing can run on. A helper that picked one would be wrong at
|
|
39
|
+
* half the call sites, and this is deliberately the only knob it takes — a third function for
|
|
40
|
+
* "positive" would be the copy this file exists to prevent.
|
|
41
|
+
*/
|
|
42
|
+
export function finiteCount(
|
|
43
|
+
subject: string,
|
|
44
|
+
option: string,
|
|
45
|
+
value: number,
|
|
46
|
+
min: 0 | 1 = 0,
|
|
47
|
+
): number {
|
|
48
|
+
assert(
|
|
49
|
+
Number.isSafeInteger(value) && value >= min,
|
|
50
|
+
`${subject} ${option} is ${String(value)}, and it counts things: it must be a whole number of ${min === 1 ? 'at least 1' : '0 or more'}, or the bound it sets is not one`,
|
|
51
|
+
`pass a whole ${option} to ${subject} — and parse an environment value before you pass it, because Number(process.env.…) is NaN when the variable is unset and Math.floor does not repair that`,
|
|
52
|
+
);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
package/src/ids.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { type Clock, systemClock } from './clock';
|
|
5
5
|
import { describeValue } from './error-render';
|
|
6
6
|
import { UltimateError } from './errors';
|
|
7
|
+
import { finiteCount } from './finite-option';
|
|
7
8
|
|
|
8
9
|
/** Nominal typing without a runtime cost. `Brand<string, 'post'>` never mixes with `'user'`. */
|
|
9
10
|
export type Brand<T, K extends string> = T & { readonly __brand: K };
|
|
@@ -39,8 +40,13 @@ function seedCounter(): number {
|
|
|
39
40
|
return (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)) & COUNTER_SEED_MASK;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* `new Uint8Array(NaN)` is a zero-length array, not a throw, so an unscreened length made this
|
|
45
|
+
* answer `''` — an id that is no id, minted silently. `min: 1`: zero bytes of randomness is the
|
|
46
|
+
* same empty string, and every caller here (`uuid`, `traceId`, `spanId`) wants a width.
|
|
47
|
+
*/
|
|
42
48
|
export function randomHex(byteLength: number): string {
|
|
43
|
-
const bytes = randomBytes(byteLength);
|
|
49
|
+
const bytes = randomBytes(finiteCount('randomHex', 'byteLength', byteLength, 1));
|
|
44
50
|
let out = '';
|
|
45
51
|
for (const byte of bytes) {
|
|
46
52
|
out += HEX[byte >> 4];
|
|
@@ -109,9 +115,15 @@ export function uuidTimestamp(id: string): Date {
|
|
|
109
115
|
return new Date(Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16));
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* URL-safe random id. Not sortable — use it for tokens and slugs, never primary keys.
|
|
120
|
+
*
|
|
121
|
+
* The default is not a screen: `??` guards nullish and `NaN` is not nullish, so
|
|
122
|
+
* `nanoid(Number(process.env.ID_LENGTH))` on an unset variable walked past `21` and answered the
|
|
123
|
+
* empty string. `min: 1` for the reason `randomHex` gives.
|
|
124
|
+
*/
|
|
113
125
|
export function nanoid(length = 21): string {
|
|
114
|
-
const bytes = randomBytes(length);
|
|
126
|
+
const bytes = randomBytes(finiteCount('nanoid', 'length', length, 1));
|
|
115
127
|
let out = '';
|
|
116
128
|
for (const byte of bytes) out += NANO_ALPHABET[byte & 63] as string;
|
|
117
129
|
return out;
|
package/src/image/pipeline.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// copy: responsive variants, blur placeholders and PWA icons are the same three steps with
|
|
4
4
|
// different numbers, and a framework that generated them twice would drift twice.
|
|
5
5
|
|
|
6
|
+
import { assert } from '../assert';
|
|
6
7
|
import { composeOnto, layOut, type ResizeSpec } from './canvas';
|
|
7
8
|
import { imageFromBunError, imageUnsupported } from './errors';
|
|
8
9
|
import { unshared } from './png-bytes';
|
|
@@ -34,6 +35,27 @@ export const canEncode = (format: string): format is EncodableFormat =>
|
|
|
34
35
|
/** 1-100, lossy formats only. Bun's own default, pinned here so output cannot drift with it. */
|
|
35
36
|
export const DEFAULT_IMAGE_QUALITY = 80;
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The encoder CLAMPS and never refuses, which is why this is a screen and not a comment. Exported
|
|
40
|
+
* because `@ultimat3/storage` mints a variant KEY from the same number without ever reaching this
|
|
41
|
+
* pipeline — two screens that refuse different qualities would mint `q150` keys for bytes this
|
|
42
|
+
* encoder goes on to refuse, which is the dead-on-arrival shape `buildSignedUrl`'s own
|
|
43
|
+
* `finiteCount` screen already exists for — that package held a private `assertFiniteSignedUrlBound`
|
|
44
|
+
* until this sweep collapsed it onto the tier-0 check. Measured
|
|
45
|
+
* on a 3x2 PNG re-encoded as JPEG: quality 80 wrote 663 bytes, and `NaN`, `-5` and `0` each wrote
|
|
46
|
+
* 635 — the worst encoding the codec can make — while `Infinity` and `1e9` each wrote 719. Every
|
|
47
|
+
* one of them silently. `Number(url.searchParams.get('q'))` is `NaN` when the parameter is absent,
|
|
48
|
+
* so the degraded image is the one an unparameterised URL gets.
|
|
49
|
+
*/
|
|
50
|
+
export const assertFiniteImageQuality = (quality: number): number => {
|
|
51
|
+
assert(
|
|
52
|
+
Number.isInteger(quality) && quality >= 1 && quality <= 100,
|
|
53
|
+
`image quality is ${String(quality)}, and the encoder clamps rather than refusing: it must be a whole number from 1 to 100`,
|
|
54
|
+
`pass a whole quality from 1 to 100 — transformImageBytes(bytes, { quality: ${String(DEFAULT_IMAGE_QUALITY)} }) — and parse a query value before you pass it`,
|
|
55
|
+
);
|
|
56
|
+
return quality;
|
|
57
|
+
};
|
|
58
|
+
|
|
37
59
|
const encodeFix =
|
|
38
60
|
"request 'png', 'jpeg' or 'webp', or route the transform through an ImageTransformDriver (a " +
|
|
39
61
|
'CDN or an external encoder) that can produce it';
|
|
@@ -86,7 +108,7 @@ export async function transformImageBytes(
|
|
|
86
108
|
if (format !== undefined && !canEncode(format)) {
|
|
87
109
|
throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format });
|
|
88
110
|
}
|
|
89
|
-
const quality = spec.quality ?? DEFAULT_IMAGE_QUALITY;
|
|
111
|
+
const quality = assertFiniteImageQuality(spec.quality ?? DEFAULT_IMAGE_QUALITY);
|
|
90
112
|
const source = await run('reading the image header', () => bunImage(bytes).metadata());
|
|
91
113
|
const output: EncodableFormat = format ?? (canEncode(source.format) ? source.format : 'png');
|
|
92
114
|
const layout = layOut(source, spec);
|
package/src/index.ts
CHANGED
|
@@ -390,6 +390,7 @@ export {
|
|
|
390
390
|
writeMasterKeyFile,
|
|
391
391
|
writeSecretsFile,
|
|
392
392
|
} from './exports/secrets';
|
|
393
|
+
export { finiteCount, finiteOption } from './finite-option';
|
|
393
394
|
export type {
|
|
394
395
|
FlightGate,
|
|
395
396
|
FlightGateLimits,
|
|
@@ -432,6 +433,7 @@ export type {
|
|
|
432
433
|
ImageTransformSpec,
|
|
433
434
|
} from './image/pipeline';
|
|
434
435
|
export {
|
|
436
|
+
assertFiniteImageQuality,
|
|
435
437
|
blurDataUrl,
|
|
436
438
|
canDecode,
|
|
437
439
|
canEncode,
|
|
@@ -453,7 +455,13 @@ export {
|
|
|
453
455
|
rasterFrom,
|
|
454
456
|
} from './image/raster';
|
|
455
457
|
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
456
|
-
export {
|
|
458
|
+
export {
|
|
459
|
+
assertLocale,
|
|
460
|
+
cachedFormatter,
|
|
461
|
+
canonicalLocale,
|
|
462
|
+
localeInvalid,
|
|
463
|
+
MAX_CACHED_FORMATTERS,
|
|
464
|
+
} from './intl-cache';
|
|
457
465
|
export { isJsonObject } from './json-object';
|
|
458
466
|
export type {
|
|
459
467
|
HealthPayload,
|
package/src/intl-cache.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// One bounded cache, on one canonical key, for every `Intl` formatter the
|
|
1
|
+
// One bounded cache, on one canonical key, and one screen, for every `Intl` formatter the
|
|
2
|
+
// framework builds.
|
|
2
3
|
// A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on the
|
|
3
4
|
// caller's spelling is memory the client chooses — 31 MB and 55.1 MB, measured `As of 2026-08` and
|
|
4
5
|
// written up in the README. The bound and the canonical key are two halves of ONE rule.
|
|
5
6
|
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
|
|
8
11
|
* and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
|
|
@@ -41,3 +44,41 @@ export function canonicalLocale(locale: string): string | undefined {
|
|
|
41
44
|
return undefined;
|
|
42
45
|
}
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A tag `Intl` cannot parse. Distinct from `@ultimat3/i18n`'s `X_LOCALE_UNSUPPORTED`, which is a
|
|
50
|
+
* well-formed tag outside the app's supported set — this one is not a tag at all, and a raw
|
|
51
|
+
* `RangeError` out of a formatter says nothing about which caller supplied it.
|
|
52
|
+
*/
|
|
53
|
+
export function localeInvalid(locale: string): UltimateError {
|
|
54
|
+
return new UltimateError({
|
|
55
|
+
code: 'X_LOCALE_INVALID',
|
|
56
|
+
cause: `"${locale}" is not a well-formed BCP 47 language tag`,
|
|
57
|
+
fix: "pass a tag like 'en', 'en-GB' or 'de-DE' — screen a header-supplied value with Intl.DateTimeFormat.supportedLocalesOf([tag]) before it reaches a formatter",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`. The ONE screen a
|
|
63
|
+
* caller-supplied BCP 47 tag passes before it reaches an `Intl` constructor.
|
|
64
|
+
*
|
|
65
|
+
* Validating and keying are one step, which is why this lives beside the cache rather than in
|
|
66
|
+
* either caller: `getCanonicalLocales` runs exactly the structural check
|
|
67
|
+
* `Intl.DateTimeFormat.supportedLocalesOf` throws on — what the `fix:` above tells the caller to
|
|
68
|
+
* run — and unlike it hands back the spelling every formatter cache keys on, so `EN-us` and
|
|
69
|
+
* `en-US` cannot mint two entries for one locale.
|
|
70
|
+
*
|
|
71
|
+
* It is tier 0 for the reason `cachedFormatter` is: `@ultimat3/time` and `@ultimat3/money` both
|
|
72
|
+
* need it and `money -> time` is a sideways import `bun run boundaries` refuses. Money was the
|
|
73
|
+
* last package still letting the tag through — a `RangeError` off an `Accept-Language` header —
|
|
74
|
+
* on the argument that this seam "decides a cache key, never whether a locale is acceptable",
|
|
75
|
+
* which is true of the cache and was never an argument for passing the tag on.
|
|
76
|
+
*
|
|
77
|
+
* Well-formed but unknown to this runtime's ICU (`zz`) is NOT refused: `Intl` falls back for
|
|
78
|
+
* those, and a user carrying a locale the runtime has no data for must still get a rendered page.
|
|
79
|
+
*/
|
|
80
|
+
export function assertLocale(locale: string): string {
|
|
81
|
+
const tag = canonicalLocale(locale);
|
|
82
|
+
if (tag === undefined) throw localeInvalid(locale);
|
|
83
|
+
return tag;
|
|
84
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Clock, systemClock } from './clock';
|
|
6
6
|
import { UltimateError } from './errors';
|
|
7
|
+
import { finiteCount } from './finite-option';
|
|
7
8
|
import { settleWithin } from './lifecycle-deadline';
|
|
8
9
|
import { lifecycleDrained } from './lifecycle-errors';
|
|
9
10
|
import { type LogFields, type Logger, logger as rootLogger } from './logger';
|
|
@@ -49,6 +50,8 @@ export interface LifecycleOptions {
|
|
|
49
50
|
* lapses and another worker re-claims it, which is what at-least-once already promises. The
|
|
50
51
|
* alternative is not "the job finishes": it is the same duplicate, delivered by SIGKILL at the
|
|
51
52
|
* kubelet's grace period, with no log line naming what overran.
|
|
53
|
+
*
|
|
54
|
+
* Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
|
|
52
55
|
*/
|
|
53
56
|
readonly deadlineMs?: number | undefined;
|
|
54
57
|
readonly clock?: Clock | undefined;
|
|
@@ -119,7 +122,15 @@ let idleWaiters: (() => void)[] = [];
|
|
|
119
122
|
const readiness = new Map<string, ReadinessCheck>();
|
|
120
123
|
|
|
121
124
|
export function configureLifecycle(options: LifecycleOptions): void {
|
|
122
|
-
|
|
125
|
+
// Screened above the write, never beside the arithmetic: `Math.max(0, deadlineAt - monotonic())`
|
|
126
|
+
// PROPAGATES a NaN into `setTimeout(fn, NaN)`, i.e. 0 — measured, one in-flight operation dropped
|
|
127
|
+
// and a 300ms close hook ABANDONED 111ms into a 25s budget, while `X_SHUTDOWN_TIMEOUT` rendered
|
|
128
|
+
// `NaNms` and told the operator to RAISE a budget that was never a number. `min: 0` because 0 is
|
|
129
|
+
// a real budget — drain now, no grace — and `@ultimat3/http`'s `drainTimeoutMs` accepts 0 and
|
|
130
|
+
// hands it straight here, so a floor of 1 would refuse at boot what that package declares.
|
|
131
|
+
if (options.deadlineMs !== undefined) {
|
|
132
|
+
deadlineMs = finiteCount('configureLifecycle', 'deadlineMs', options.deadlineMs, 0);
|
|
133
|
+
}
|
|
123
134
|
if (options.clock !== undefined) {
|
|
124
135
|
clock = options.clock;
|
|
125
136
|
startedAtMono = clock.monotonic();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Single responsibility: the identifier grammar every metric name and every LABEL name obeys, and
|
|
2
|
+
// the one code that refuses a value outside it. Split out of `metrics.ts` because that file is at
|
|
3
|
+
// the 500-line ceiling `x verify`'s `filesize` step enforces, and because the two refusals are one
|
|
4
|
+
// rule asked of two things — the exposition format spells them identically.
|
|
5
|
+
|
|
6
|
+
import { type CodedErrorInit, UltimateError } from './errors';
|
|
7
|
+
import type { MetricAttributes } from './metrics-types';
|
|
8
|
+
|
|
9
|
+
export class MetricNameInvalidError extends UltimateError {
|
|
10
|
+
static readonly code = 'X_METRIC_NAME_INVALID';
|
|
11
|
+
override readonly name = 'MetricNameInvalidError';
|
|
12
|
+
constructor(init: CodedErrorInit) {
|
|
13
|
+
super({ ...init, code: MetricNameInvalidError.code });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lowercase snake, the intersection of what every exposition format accepts. OTel's dotted names
|
|
19
|
+
* survive an OTLP hop but not a Prometheus scrape, and the autoscaler reads the scrape.
|
|
20
|
+
*/
|
|
21
|
+
export const METRIC_NAME_RE = /^[a-z_][a-z0-9_]*$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The suggestion both `fix:` lines below hand back, and it has to SATISFY `METRIC_NAME_RE`, not
|
|
25
|
+
* merely resemble it: the grammar's first character is `[a-z_]`, so lowercasing `2digits` left
|
|
26
|
+
* `2digits` and the refusal told the caller to rename the label to the same name it had just
|
|
27
|
+
* refused. An underscore prefix is the one repair that keeps the rest of the identifier — which is
|
|
28
|
+
* also what makes the empty string a nameable `_` rather than a second refusal.
|
|
29
|
+
*/
|
|
30
|
+
const snake = (value: string): string => {
|
|
31
|
+
const lowered = value.toLowerCase().replaceAll(/[^a-z0-9_]/g, '_');
|
|
32
|
+
return /^[a-z_]/.test(lowered) ? lowered : `_${lowered}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Refused at DECLARATION, where the instrument is still nameable. */
|
|
36
|
+
export function assertMetricName(name: string): void {
|
|
37
|
+
if (METRIC_NAME_RE.test(name)) return;
|
|
38
|
+
throw new MetricNameInvalidError({
|
|
39
|
+
cause: `"${name}" is not lowercase snake_case matching ${METRIC_NAME_RE.source}`,
|
|
40
|
+
fix: `rename the instrument to lowercase snake_case, e.g. ${snake(name)}`,
|
|
41
|
+
meta: { name },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A LABEL name obeys that same grammar, and it was guarded nowhere: `metrics-text.ts` escapes the
|
|
47
|
+
* label VALUE and interpolates the KEY raw, so `bad"key` rendered `orders_total{bad"key="x"} 1`
|
|
48
|
+
* and a `\n` in one split the body into a line the scraper reads as a different series. A scraper
|
|
49
|
+
* rejects the WHOLE response over one unparseable line, so `http_requests_total`, `connections`
|
|
50
|
+
* and `queue_depth` — the three the chart's HPA reads — disappear together, and the autoscaler
|
|
51
|
+
* sees no signal rather than a bad one.
|
|
52
|
+
*
|
|
53
|
+
* Refused on the way in, never sanitised at render: a silently renamed label is a different
|
|
54
|
+
* series, and the point would land somewhere the dashboard querying the declared name cannot see.
|
|
55
|
+
* Once per label set the instrument has not seen — `seriesFor` calls this on its MISS, ahead of
|
|
56
|
+
* the cardinality ceiling, because the overflow branch answers without allocating a series and a
|
|
57
|
+
* check hung off the allocation would have made the refusal a function of how busy the process is.
|
|
58
|
+
*/
|
|
59
|
+
export function assertLabelNames(metric: string, attributes: MetricAttributes): void {
|
|
60
|
+
for (const label of Object.keys(attributes)) {
|
|
61
|
+
if (METRIC_NAME_RE.test(label)) continue;
|
|
62
|
+
throw new MetricNameInvalidError({
|
|
63
|
+
cause: `${metric} was given the label "${label}", which is not lowercase snake_case matching ${METRIC_NAME_RE.source} — the exposition line it renders is one no scraper can parse, and one bad line drops the whole scrape`,
|
|
64
|
+
fix: `rename the label at the call site to lowercase snake_case, e.g. { ${snake(label)}: … }`,
|
|
65
|
+
meta: { metric, label },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/metrics-text.ts
CHANGED
|
@@ -14,6 +14,12 @@ function escapeLabel(value: string): string {
|
|
|
14
14
|
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The KEY is interpolated bare and the VALUE is escaped, which is the whole grammar: a label name
|
|
19
|
+
* is an identifier `metrics.ts` refuses at series creation (`X_METRIC_NAME_INVALID`), a label
|
|
20
|
+
* value is arbitrary text. Never sanitise a key here — a renamed label is a different series, and
|
|
21
|
+
* this function has no way to tell the dashboard that.
|
|
22
|
+
*/
|
|
17
23
|
function labels(attributes: MetricAttributes, extra?: readonly [string, string]): string {
|
|
18
24
|
const pairs = Object.entries(attributes)
|
|
19
25
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
package/src/metrics.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// aggregated in process, read through one `collectMetrics()`. Shaped exactly like `telemetry.ts`:
|
|
3
3
|
// always on, a no-op exporter by default, and the wire format supplied by a driver, never here.
|
|
4
4
|
|
|
5
|
+
import { assert } from './assert';
|
|
5
6
|
import { type Clock, systemClock } from './clock';
|
|
6
7
|
import { renderThrowable } from './error-render';
|
|
7
8
|
import { type CodedErrorInit, UltimateError } from './errors';
|
|
8
9
|
import { logger } from './logger';
|
|
10
|
+
import { assertLabelNames, assertMetricName, MetricNameInvalidError } from './metric-names';
|
|
9
11
|
import type {
|
|
10
12
|
Counter,
|
|
11
13
|
Gauge,
|
|
@@ -22,8 +24,10 @@ import type {
|
|
|
22
24
|
} from './metrics-types';
|
|
23
25
|
import { serviceResource } from './telemetry';
|
|
24
26
|
|
|
25
|
-
// The data model
|
|
26
|
-
// a metric type from here
|
|
27
|
+
// The data model and the identifier grammar are modules of their own; the public surface is
|
|
28
|
+
// unchanged, so nothing that imports a metric type or the name error from here learns a second
|
|
29
|
+
// path.
|
|
30
|
+
export { MetricNameInvalidError } from './metric-names';
|
|
27
31
|
export type {
|
|
28
32
|
Counter,
|
|
29
33
|
Gauge,
|
|
@@ -42,14 +46,6 @@ export type {
|
|
|
42
46
|
ReadableMetric,
|
|
43
47
|
} from './metrics-types';
|
|
44
48
|
|
|
45
|
-
export class MetricNameInvalidError extends UltimateError {
|
|
46
|
-
static readonly code = 'X_METRIC_NAME_INVALID';
|
|
47
|
-
override readonly name = 'MetricNameInvalidError';
|
|
48
|
-
constructor(init: CodedErrorInit) {
|
|
49
|
-
super({ ...init, code: MetricNameInvalidError.code });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
49
|
export class MetricValueInvalidError extends UltimateError {
|
|
54
50
|
static readonly code = 'X_METRIC_VALUE_INVALID';
|
|
55
51
|
override readonly name = 'MetricValueInvalidError';
|
|
@@ -88,12 +84,6 @@ export const DEFAULT_HISTOGRAM_BOUNDS: readonly number[] = Object.freeze([
|
|
|
88
84
|
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
|
|
89
85
|
]);
|
|
90
86
|
|
|
91
|
-
/**
|
|
92
|
-
* Lowercase snake, the intersection of what every exposition format accepts. OTel's dotted names
|
|
93
|
-
* survive an OTLP hop but not a Prometheus scrape, and the autoscaler reads the scrape.
|
|
94
|
-
*/
|
|
95
|
-
const METRIC_NAME_RE = /^[a-z_][a-z0-9_]*$/;
|
|
96
|
-
|
|
97
87
|
export const noopMetricExporter: MetricExporter = Object.freeze({
|
|
98
88
|
export(): void {
|
|
99
89
|
// Intentionally empty: instruments are always live, and free until an exporter is configured.
|
|
@@ -231,13 +221,7 @@ function assertBounds(name: string, bounds: readonly number[] | undefined): void
|
|
|
231
221
|
}
|
|
232
222
|
|
|
233
223
|
function declare(name: string, kind: MetricKind, options: GaugeOptions & HistogramOptions) {
|
|
234
|
-
|
|
235
|
-
throw new MetricNameInvalidError({
|
|
236
|
-
cause: `"${name}" is not lowercase snake_case matching ${METRIC_NAME_RE.source}`,
|
|
237
|
-
fix: `rename the instrument to lowercase snake_case, e.g. ${name.toLowerCase().replaceAll(/[^a-z0-9_]/g, '_')}`,
|
|
238
|
-
meta: { name },
|
|
239
|
-
});
|
|
240
|
-
}
|
|
224
|
+
assertMetricName(name);
|
|
241
225
|
assertBounds(name, options.bounds);
|
|
242
226
|
const existing = instruments.get(name);
|
|
243
227
|
if (existing !== undefined) {
|
|
@@ -367,6 +351,12 @@ function seriesFor(instrument: Instrument, attributes: MetricAttributes): Series
|
|
|
367
351
|
const key = seriesKey(attributes);
|
|
368
352
|
const found = instrument.series.get(key);
|
|
369
353
|
if (found !== undefined) return found;
|
|
354
|
+
// On the MISS, ahead of the ceiling — never inside `createSeries`, which the overflow branch
|
|
355
|
+
// below returns without reaching. A screen that ran only where a series is BORN was a screen
|
|
356
|
+
// that depended on load: `bad"key` threw on a fresh process and was swallowed on a busy one,
|
|
357
|
+
// once the instrument had filled up, which is exactly when an unparseable label is likeliest to
|
|
358
|
+
// arrive. A hit needs no check — a key in the map passed this on the way in.
|
|
359
|
+
assertLabelNames(instrument.descriptor.name, attributes);
|
|
370
360
|
if (instrument.series.size >= instrument.maxSeries) {
|
|
371
361
|
reportOverflow(instrument);
|
|
372
362
|
// Created directly rather than through this function again: the overflow series is the ONE
|
|
@@ -485,6 +475,14 @@ export function exportMetrics(): void {
|
|
|
485
475
|
* — the drain hook, not this timer, decides when the process may leave.
|
|
486
476
|
*/
|
|
487
477
|
export function startMetricExport(intervalMs = 60_000): () => void {
|
|
478
|
+
// A timer given a non-finite delay does not export less often — `setInterval(fn, NaN)` runs at
|
|
479
|
+
// 1ms in this Bun, measured, so a `Number(process.env.METRICS_INTERVAL_MS)` on an unset variable
|
|
480
|
+
// pushes a thousand collections a second for the life of the process.
|
|
481
|
+
assert(
|
|
482
|
+
Number.isSafeInteger(intervalMs) && intervalMs >= 1,
|
|
483
|
+
`startMetricExport was given ${String(intervalMs)}ms; an export interval is a whole number of at least 1ms, and a timer given anything else runs at 1ms`,
|
|
484
|
+
"pass a whole number of milliseconds — startMetricExport(60_000) — and parse an environment value first: Number(process.env.METRICS_INTERVAL_MS ?? '') is NaN when the variable is unset",
|
|
485
|
+
);
|
|
488
486
|
const timer = setInterval(exportMetrics, intervalMs);
|
|
489
487
|
timer.unref();
|
|
490
488
|
return () => clearInterval(timer);
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ReadableMetric,
|
|
12
12
|
} from './metrics';
|
|
13
13
|
import {
|
|
14
|
+
assertFiniteOtlpBound,
|
|
14
15
|
OTLP_SCOPE,
|
|
15
16
|
otlpAttributes,
|
|
16
17
|
otlpEndpoint,
|
|
@@ -118,7 +119,7 @@ export interface OtlpMetricExporter extends MetricExporter {
|
|
|
118
119
|
export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): OtlpMetricExporter {
|
|
119
120
|
const url = otlpEndpoint('metrics', options.endpoint);
|
|
120
121
|
const headers = otlpHeaders(options.headers);
|
|
121
|
-
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
122
|
+
const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
|
|
122
123
|
const send = options.fetch ?? globalThis.fetch;
|
|
123
124
|
let startedAtMs = options.startedAtMs;
|
|
124
125
|
let inflight: Promise<void> = Promise.resolve();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { renderThrowable } from './error-render';
|
|
5
5
|
import { logger } from './logger';
|
|
6
6
|
import {
|
|
7
|
+
assertFiniteOtlpBound,
|
|
7
8
|
OTLP_SCOPE,
|
|
8
9
|
type OtlpKeyValue,
|
|
9
10
|
otlpAttributes,
|
|
@@ -126,9 +127,13 @@ export interface OtlpSpanExporter extends SpanExporter {
|
|
|
126
127
|
export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpanExporter {
|
|
127
128
|
const url = otlpEndpoint('traces', options.endpoint);
|
|
128
129
|
const headers = otlpHeaders(options.headers);
|
|
129
|
-
const maxBatchSize = options.maxBatchSize ?? 512;
|
|
130
|
-
const maxQueueSize = options.maxQueueSize ?? 2048;
|
|
131
|
-
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
130
|
+
const maxBatchSize = assertFiniteOtlpBound('maxBatchSize', options.maxBatchSize ?? 512);
|
|
131
|
+
const maxQueueSize = assertFiniteOtlpBound('maxQueueSize', options.maxQueueSize ?? 2048);
|
|
132
|
+
const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
|
|
133
|
+
const flushIntervalMs = assertFiniteOtlpBound(
|
|
134
|
+
'flushIntervalMs',
|
|
135
|
+
options.flushIntervalMs ?? 5_000,
|
|
136
|
+
);
|
|
132
137
|
const send = options.fetch ?? globalThis.fetch;
|
|
133
138
|
const queue: ReadableSpan[] = [];
|
|
134
139
|
let inflight: Promise<void> = Promise.resolve();
|
|
@@ -173,7 +178,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
173
178
|
|
|
174
179
|
// Unref'd, so a pending flush never holds a draining process open — `shutdown()` is what
|
|
175
180
|
// decides the last batch leaves, exactly as `startMetricExport` defers to the drain hook.
|
|
176
|
-
const timer = setInterval(() => void drainQueue(),
|
|
181
|
+
const timer = setInterval(() => void drainQueue(), flushIntervalMs);
|
|
177
182
|
timer.unref();
|
|
178
183
|
|
|
179
184
|
return {
|
package/src/otlp.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// an operator already sets, header parsing, the OTLP/JSON value encoding, and one POST that never
|
|
3
3
|
// throws. A serialisation, not a vendor (axiom 7), exactly as `metrics-text.ts` is to Prometheus.
|
|
4
4
|
|
|
5
|
+
import { assert } from './assert';
|
|
5
6
|
import { renderThrowable } from './error-render';
|
|
6
7
|
import { type CodedErrorInit, UltimateError } from './errors';
|
|
7
8
|
import { logger } from './logger';
|
|
@@ -111,6 +112,29 @@ export function tryOtlpEndpoint(
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** The endpoint, or a coded error naming the variable to set. */
|
|
115
|
+
/**
|
|
116
|
+
* A batch size, a queue bound or a millisecond budget, screened where the exporter is CONSTRUCTED.
|
|
117
|
+
*
|
|
118
|
+
* These arrive as `Number(process.env.OTEL_…)` as often as literals, and `NaN` is not nullish so
|
|
119
|
+
* `??` keeps it. What follows is never a wrong number: `setInterval(fn, NaN)` runs at 1ms in this
|
|
120
|
+
* Bun (measured, with a `TimeoutNaNWarning`), so the collector is POSTed a thousand times a
|
|
121
|
+
* second; `AbortSignal.timeout(NaN)` throws, and `postOtlp` swallows it, so every export fails
|
|
122
|
+
* with one warn line and the traces simply stop; and `queue.length >= NaN` is false, so a bound
|
|
123
|
+
* that exists to be a bound stops being one. `X_INVARIANT` rather than a fourth `X_OTLP_*` code:
|
|
124
|
+
* the three here name the endpoint, the headers and the protocol, and this is none of those.
|
|
125
|
+
*
|
|
126
|
+
* The `Finite` in the name is load-bearing: `bun run finite-bounds` recognises a repair by the
|
|
127
|
+
* shape of the CALL, so a screen named for its subject alone is invisible to the ratchet.
|
|
128
|
+
*/
|
|
129
|
+
export function assertFiniteOtlpBound(option: string, value: number): number {
|
|
130
|
+
assert(
|
|
131
|
+
Number.isSafeInteger(value) && value >= 1,
|
|
132
|
+
`otlp exporter option ${option} is ${String(value)}; it must be a whole number of at least 1 — a comparison against a non-finite bound is false for every value, and a timer given one runs at 1ms`,
|
|
133
|
+
`pass a whole number for ${option}, and parse an environment value before you pass it: Number(process.env.OTEL_BSP_SCHEDULE_DELAY ?? '') is NaN when the variable is unset`,
|
|
134
|
+
);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
export function otlpEndpoint(
|
|
115
139
|
signal: OtlpSignal,
|
|
116
140
|
explicit?: string | undefined,
|
package/src/read-capped.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// `@ultimat3/mcp` (tier 4) serves a bare `Request` that never passes through http's pipeline —
|
|
5
5
|
// `await request.json()` there was governed only by Bun's 128 MiB default.
|
|
6
6
|
|
|
7
|
+
import { assert } from './assert';
|
|
8
|
+
|
|
7
9
|
/** What a body read produced: the bytes, or the running total at the moment it went over. */
|
|
8
10
|
export type CappedBody = { readonly bytes: Uint8Array } | { readonly over: number };
|
|
9
11
|
|
|
@@ -17,6 +19,18 @@ export const readWithinLimit = async (
|
|
|
17
19
|
body: ReadableStream<Uint8Array> | null,
|
|
18
20
|
limit: number,
|
|
19
21
|
): Promise<CappedBody> => {
|
|
22
|
+
// The cap is this function's whole contract, so it is screened here and not by each caller in
|
|
23
|
+
// turn. `total > NaN` is false for every chunk, so a non-finite limit does not RAISE the ceiling
|
|
24
|
+
// — it removes it, and the payload the caller was promised would never be held in full is held
|
|
25
|
+
// in full. Both callers reach it from configuration (`@ultimat3/http`'s `bodyLimitBytes`,
|
|
26
|
+
// `@ultimat3/mcp`'s own), and `Number(process.env.…)` on an unset variable is `NaN`. Before the
|
|
27
|
+
// body is touched: a cap this wrong is the caller's bug, not the peer's, and cancelling their
|
|
28
|
+
// stream over it would be answering the wrong party.
|
|
29
|
+
assert(
|
|
30
|
+
Number.isSafeInteger(limit) && limit >= 0,
|
|
31
|
+
`readWithinLimit was given a limit of ${String(limit)}; a byte cap is a whole number of zero or more, and every comparison against a non-finite one is false, so the read is unbounded rather than capped`,
|
|
32
|
+
"pass a whole number of bytes — readWithinLimit(request.body, 1_048_576) — and parse an environment value before you pass it: Number(process.env.BODY_LIMIT_BYTES ?? '') is NaN when the variable is unset",
|
|
33
|
+
);
|
|
20
34
|
if (body === null) return { bytes: new Uint8Array(0) };
|
|
21
35
|
const reader = body.getReader();
|
|
22
36
|
const chunks: Uint8Array[] = [];
|
|
@@ -26,15 +40,19 @@ export const readWithinLimit = async (
|
|
|
26
40
|
const { done, value } = await reader.read();
|
|
27
41
|
if (done) break;
|
|
28
42
|
total += value.byteLength;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return { over: total };
|
|
34
|
-
}
|
|
43
|
+
// Cancelled rather than drained, in the `finally` below: the peer is told to stop sending,
|
|
44
|
+
// and nothing past the cap is ever held. Draining is how a rejected request still costs its
|
|
45
|
+
// full transfer.
|
|
46
|
+
if (total > limit) return { over: total };
|
|
35
47
|
chunks.push(value);
|
|
36
48
|
}
|
|
37
49
|
} finally {
|
|
50
|
+
// Cancel rather than merely release — `@ultimat3/storage`'s `readWithin` is the same bounded
|
|
51
|
+
// read and states the same rule: a refused body must stop arriving, not keep filling a socket
|
|
52
|
+
// buffer nobody will read. Swallowed, because a cancellation that fails on the way out must
|
|
53
|
+
// not replace the answer this function computed: an unguarded `cancel()` turned the 413 into
|
|
54
|
+
// whatever the stream threw. A completed stream answers it as a no-op.
|
|
55
|
+
await reader.cancel().catch(() => undefined);
|
|
38
56
|
reader.releaseLock();
|
|
39
57
|
}
|
|
40
58
|
const bytes = new Uint8Array(total);
|
package/src/sampler.ts
CHANGED
|
@@ -9,9 +9,20 @@ import type { SpanAttributes, SpanContext } from './telemetry';
|
|
|
9
9
|
* The seam. `parent` is the inbound span context when there is one — an upstream that decided
|
|
10
10
|
* "not sampled" has said so in `parent.traceFlags`, and a sampler that ignores it splits one
|
|
11
11
|
* distributed trace into a sampled half and an unsampled half, which is worse than either.
|
|
12
|
+
*
|
|
13
|
+
* `traceId` is the id of the span being CREATED — the parent's carried forward, or the one this
|
|
14
|
+
* process just minted for a root. It is an argument of its own for the same reason OTel makes it
|
|
15
|
+
* one: a trace-id-hashing sampler has to answer for a ROOT too, where there is no parent to read
|
|
16
|
+
* the id off, and every span of one trace has to reach the same answer or the collector is handed
|
|
17
|
+
* orphans. An implementation that ignores it (`always_on`, `parentbased_*`) simply omits it.
|
|
12
18
|
*/
|
|
13
19
|
export interface Sampler {
|
|
14
|
-
shouldSample(
|
|
20
|
+
shouldSample(
|
|
21
|
+
name: string,
|
|
22
|
+
parent: SpanContext | undefined,
|
|
23
|
+
attributes: SpanAttributes,
|
|
24
|
+
traceId: string,
|
|
25
|
+
): boolean;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export const OTEL_SAMPLER_KEY = 'OTEL_TRACES_SAMPLER';
|
|
@@ -55,13 +66,38 @@ export function parentBasedRatioSampler(
|
|
|
55
66
|
};
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
/**
|
|
69
|
+
/** The 64-bit space OTel's `TraceIdRatioBased` compares the low 8 bytes of a trace id against. */
|
|
70
|
+
const TRACE_ID_SPACE = 2 ** 64;
|
|
71
|
+
|
|
72
|
+
const TRACE_ID_HEX = /^[0-9a-f]{32}$/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* OTel's `TraceIdRatioBased`: the LOW 8 bytes of the trace id as a big-endian unsigned integer,
|
|
76
|
+
* against `ratio` of the 2^64 space. Answers `undefined` for anything that is not a 32-hex trace
|
|
77
|
+
* id, so the caller decides what to do with an id no hash can read rather than this silently
|
|
78
|
+
* sampling or silently dropping it.
|
|
79
|
+
*/
|
|
80
|
+
function traceIdSampled(traceId: string, ratio: number): boolean | undefined {
|
|
81
|
+
if (!TRACE_ID_HEX.test(traceId)) return undefined;
|
|
82
|
+
return BigInt(`0x${traceId.slice(16)}`) < BigInt(Math.floor(ratio * TRACE_ID_SPACE));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `traceidratio` — the OTel spelling that ignores the inbound decision and hashes the TRACE ID.
|
|
87
|
+
*
|
|
88
|
+
* The hash is the whole of it, and a roll is not a substitute: `startSpan` asks the sampler for
|
|
89
|
+
* every span, root and child alike, so a `random()` here re-decided per span and one trace left
|
|
90
|
+
* as a sampled half and an unsampled half — measured at 0.5, an unexported root with eight of its
|
|
91
|
+
* twenty children at the collector. Hashing the id makes the answer a function of the trace, which
|
|
92
|
+
* is what the name of this sampler means and what lets a second SERVICE agree with us unprompted.
|
|
93
|
+
* `random` survives only for an id no hash can read, and stays injectable so that path is a test.
|
|
94
|
+
*/
|
|
59
95
|
export function ratioSampler(ratio: number, random: () => number = Math.random): Sampler {
|
|
60
96
|
return {
|
|
61
|
-
shouldSample(): boolean {
|
|
97
|
+
shouldSample(_name, _parent, _attributes, traceId): boolean {
|
|
62
98
|
if (ratio >= 1) return true;
|
|
63
99
|
if (ratio <= 0) return false;
|
|
64
|
-
return random() < ratio;
|
|
100
|
+
return traceIdSampled(traceId, ratio) ?? random() < ratio;
|
|
65
101
|
},
|
|
66
102
|
};
|
|
67
103
|
}
|
package/src/telemetry.ts
CHANGED
|
@@ -201,10 +201,13 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
201
201
|
// so one trace is sampled or not sampled as a whole. Before this, `traceFlags` was hardcoded to
|
|
202
202
|
// 1 for a root and `end()` exported regardless, which made the bit a value the framework
|
|
203
203
|
// forwarded and nobody obeyed.
|
|
204
|
+
// The id is resolved BEFORE the decision, because the decision may be a function of it: a
|
|
205
|
+
// `traceidratio` sampler hashes the trace id, and a root's id exists nowhere else to hash.
|
|
206
|
+
const traceId = parent?.traceId ?? newTraceId();
|
|
204
207
|
const context: SpanContext = {
|
|
205
|
-
traceId
|
|
208
|
+
traceId,
|
|
206
209
|
spanId: newSpanId(),
|
|
207
|
-
traceFlags: currentSampler().shouldSample(name, inbound, attributes) ? 1 : 0,
|
|
210
|
+
traceFlags: currentSampler().shouldSample(name, inbound, attributes, traceId) ? 1 : 0,
|
|
208
211
|
};
|
|
209
212
|
const events: SpanEvent[] = [];
|
|
210
213
|
const startedAt = clock.now().getTime();
|