@ultimat3/core 16.0.0 → 18.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 +62 -18
- package/README.md +21 -4
- package/package.json +5 -3
- package/src/backoff.ts +19 -7
- package/src/config-pwa.ts +118 -0
- package/src/config.ts +74 -25
- package/src/context.ts +35 -3
- package/src/error-codes.ts +3 -0
- package/src/error-render.ts +10 -53
- 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 +14 -3
- package/src/intl-cache.ts +88 -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/schema-error-codes.ts +27 -25
- package/src/telemetry.ts +5 -2
- package/src/time-zone-name.ts +13 -42
package/src/error-render.ts
CHANGED
|
@@ -252,57 +252,14 @@ export function renderMetaRecord(
|
|
|
252
252
|
* There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
|
|
253
253
|
* from being the same breach.
|
|
254
254
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
case 'string':
|
|
265
|
-
return countOf(charCount(value), 'string', 'character');
|
|
266
|
-
case 'number':
|
|
267
|
-
return describeNumber(value);
|
|
268
|
-
case 'boolean':
|
|
269
|
-
return 'a boolean';
|
|
270
|
-
case 'bigint':
|
|
271
|
-
return 'a bigint';
|
|
272
|
-
case 'symbol':
|
|
273
|
-
return 'a symbol';
|
|
274
|
-
case 'function':
|
|
275
|
-
return 'a function';
|
|
276
|
-
default:
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
|
|
280
|
-
// `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
|
|
281
|
-
if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
|
|
282
|
-
return 'an object';
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function describeNumber(value: number): string {
|
|
286
|
-
if (Number.isNaN(value)) return 'NaN';
|
|
287
|
-
if (value === Number.POSITIVE_INFINITY) return 'Infinity';
|
|
288
|
-
if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
|
|
289
|
-
return 'a number';
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function countOf(size: number, noun: string, unit: string): string {
|
|
293
|
-
if (size === 0) return `an empty ${noun}`;
|
|
294
|
-
const article = noun === 'array' ? 'an' : 'a';
|
|
295
|
-
return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* The twin of `@ultimat3/schema`'s `char-count.ts`, duplicated for the same reason `describeValue`
|
|
300
|
-
* is: both packages are tier 0 and neither may import the other. Code points, because the rules
|
|
301
|
-
* that reject a string count in them and the message must quote the same unit — `'👍'.length` is 2.
|
|
302
|
-
* Only a surrogate makes the two counts differ, so every ASCII value keeps the O(1) read.
|
|
255
|
+
* DECLARED IN `@ultimat3/schema` and re-exported here, `As of 2026-08-27`. It was a
|
|
256
|
+
* character-for-character copy of `packages/schema/src/describe-value.ts` — the two tier-0
|
|
257
|
+
* packages could not import each other — held equal by a 63-line behavioural pin in
|
|
258
|
+
* `@ultimat3/cli`, a TIER-5 package, that no rule required to exist. Whose function it is has not
|
|
259
|
+
* changed: schema's `expected()` is the other reader, and a widening that leaks a value is now one
|
|
260
|
+
* edit in one file rather than two files that can drift apart in silence.
|
|
261
|
+
*
|
|
262
|
+
* `charCount` went with it — the same duplication, one call deep — so the unit a message quotes
|
|
263
|
+
* and the unit a length rule counts in are the same code, not two files that agree today.
|
|
303
264
|
*/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
function charCount(value: string): number {
|
|
307
|
-
return HAS_SURROGATE.test(value) ? [...value].length : value.length;
|
|
308
|
-
}
|
|
265
|
+
export { describeValue } from '@ultimat3/schema';
|
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
|
@@ -65,13 +65,15 @@ export type {
|
|
|
65
65
|
DatabaseConfig,
|
|
66
66
|
JobsConfig,
|
|
67
67
|
McpConfig,
|
|
68
|
-
|
|
68
|
+
NotifyConfig,
|
|
69
69
|
RealtimeConfig,
|
|
70
70
|
RealtimeTransport,
|
|
71
71
|
ThemeConfig,
|
|
72
72
|
ThemeMode,
|
|
73
73
|
} from './config';
|
|
74
|
-
export { defineConfig } from './config';
|
|
74
|
+
export { defineConfig, INBOX_RETENTION_KEYS } from './config';
|
|
75
|
+
export type { PwaColors, PwaConfig, PwaSchemeColors } from './config-pwa';
|
|
76
|
+
export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
|
|
75
77
|
export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
|
|
76
78
|
export {
|
|
77
79
|
createContext,
|
|
@@ -390,6 +392,7 @@ export {
|
|
|
390
392
|
writeMasterKeyFile,
|
|
391
393
|
writeSecretsFile,
|
|
392
394
|
} from './exports/secrets';
|
|
395
|
+
export { finiteCount, finiteOption } from './finite-option';
|
|
393
396
|
export type {
|
|
394
397
|
FlightGate,
|
|
395
398
|
FlightGateLimits,
|
|
@@ -432,6 +435,7 @@ export type {
|
|
|
432
435
|
ImageTransformSpec,
|
|
433
436
|
} from './image/pipeline';
|
|
434
437
|
export {
|
|
438
|
+
assertFiniteImageQuality,
|
|
435
439
|
blurDataUrl,
|
|
436
440
|
canDecode,
|
|
437
441
|
canEncode,
|
|
@@ -453,7 +457,14 @@ export {
|
|
|
453
457
|
rasterFrom,
|
|
454
458
|
} from './image/raster';
|
|
455
459
|
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
456
|
-
export {
|
|
460
|
+
export {
|
|
461
|
+
assertLocale,
|
|
462
|
+
cachedFormatter,
|
|
463
|
+
canonicalLocale,
|
|
464
|
+
localeInvalid,
|
|
465
|
+
MAX_CACHED_FORMATTERS,
|
|
466
|
+
MAX_LOCALE_EXCERPT,
|
|
467
|
+
} from './intl-cache';
|
|
457
468
|
export { isJsonObject } from './json-object';
|
|
458
469
|
export type {
|
|
459
470
|
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
|
|
@@ -24,6 +27,16 @@ export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: ()
|
|
|
24
27
|
return formatter;
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The cap on the tag an `X_LOCALE_INVALID` cause quotes back, in code points.
|
|
32
|
+
*
|
|
33
|
+
* 35 is RFC 5646 §4.4.1's own number — Figure 7 derives it as the longest tag the registry can
|
|
34
|
+
* form (language 8 + script 5 + region 4 + two variants 9+9), and the same section says a protocol
|
|
35
|
+
* with a fixed buffer "MUST allow for language tags of at least 35 characters". So every tag a
|
|
36
|
+
* caller could legitimately have meant fits, and nothing longer is a tag being debugged.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_LOCALE_EXCERPT = 35;
|
|
39
|
+
|
|
27
40
|
/**
|
|
28
41
|
* The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
|
|
29
42
|
* (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
|
|
@@ -41,3 +54,77 @@ export function canonicalLocale(locale: string): string | undefined {
|
|
|
41
54
|
return undefined;
|
|
42
55
|
}
|
|
43
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A tag `Intl` cannot parse. Distinct from `@ultimat3/i18n`'s `X_LOCALE_UNSUPPORTED`, which is a
|
|
60
|
+
* well-formed tag outside the app's supported set — this one is not a tag at all, and a raw
|
|
61
|
+
* `RangeError` out of a formatter says nothing about which caller supplied it.
|
|
62
|
+
*/
|
|
63
|
+
export function localeInvalid(locale: string): UltimateError {
|
|
64
|
+
return new UltimateError({
|
|
65
|
+
code: 'X_LOCALE_INVALID',
|
|
66
|
+
cause: `${localeExcerpt(locale)} is not a well-formed BCP 47 language tag`,
|
|
67
|
+
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",
|
|
68
|
+
// The raw tag, under a NAME. A `cause` is copied into the 400 body and into the log line by
|
|
69
|
+
// `@ultimat3/http`'s `toProblem`, and a logger redacts by key — so a caller's value spliced
|
|
70
|
+
// into prose has no key left to redact, which is the whole argument `describeValue` rests on.
|
|
71
|
+
// `meta` is the key, and it is machine-read, so it carries the value WHOLE: an excerpt here
|
|
72
|
+
// would be a value a redactor or a bug report reads as complete when it is not.
|
|
73
|
+
meta: { locale },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The tag as a reader can act on it: the first `MAX_LOCALE_EXCERPT` code points, quoted, and SAID
|
|
79
|
+
* to be cut when there were more.
|
|
80
|
+
*
|
|
81
|
+
* `describeValue` is the usual answer for a caller-supplied value in a `cause` and it is the wrong
|
|
82
|
+
* one here — it renders `en_US` as "a 5-character string", deleting the only actionable content in
|
|
83
|
+
* a sentence whose entire job is to say WHICH tag was refused. Bounding it keeps the diagnostic and
|
|
84
|
+
* removes the part a stranger chooses: without a cap the whole of an `Accept-Language` value —
|
|
85
|
+
* megabytes, at the caller's option — became the error's `message`, its 400 body and its log line.
|
|
86
|
+
*
|
|
87
|
+
* Code points, never `slice`: cutting between a surrogate pair leaves a lone surrogate, which is
|
|
88
|
+
* not text and survives no encoder between here and the log index. The string iterator is lazy, so
|
|
89
|
+
* a megabyte tag costs 35 steps rather than a megabyte-long array.
|
|
90
|
+
*
|
|
91
|
+
* No escaping here: `UltimateError`'s constructor runs `singleLine` over every line-bearing field
|
|
92
|
+
* exactly once, which is what keeps a newline in a tag from writing a second line an operator reads
|
|
93
|
+
* as a genuine framework message. A second pass at this call site would be a second place that has
|
|
94
|
+
* to be right.
|
|
95
|
+
*/
|
|
96
|
+
function localeExcerpt(locale: string): string {
|
|
97
|
+
let head = '';
|
|
98
|
+
let taken = 0;
|
|
99
|
+
for (const char of locale) {
|
|
100
|
+
if (taken === MAX_LOCALE_EXCERPT) return `"${head}" (truncated at ${taken} characters)`;
|
|
101
|
+
head += char;
|
|
102
|
+
taken += 1;
|
|
103
|
+
}
|
|
104
|
+
return `"${head}"`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`. The ONE screen a
|
|
109
|
+
* caller-supplied BCP 47 tag passes before it reaches an `Intl` constructor.
|
|
110
|
+
*
|
|
111
|
+
* Validating and keying are one step, which is why this lives beside the cache rather than in
|
|
112
|
+
* either caller: `getCanonicalLocales` runs exactly the structural check
|
|
113
|
+
* `Intl.DateTimeFormat.supportedLocalesOf` throws on — what the `fix:` above tells the caller to
|
|
114
|
+
* run — and unlike it hands back the spelling every formatter cache keys on, so `EN-us` and
|
|
115
|
+
* `en-US` cannot mint two entries for one locale.
|
|
116
|
+
*
|
|
117
|
+
* It is tier 0 for the reason `cachedFormatter` is: `@ultimat3/time` and `@ultimat3/money` both
|
|
118
|
+
* need it and `money -> time` is a sideways import `bun run boundaries` refuses. Money was the
|
|
119
|
+
* last package still letting the tag through — a `RangeError` off an `Accept-Language` header —
|
|
120
|
+
* on the argument that this seam "decides a cache key, never whether a locale is acceptable",
|
|
121
|
+
* which is true of the cache and was never an argument for passing the tag on.
|
|
122
|
+
*
|
|
123
|
+
* Well-formed but unknown to this runtime's ICU (`zz`) is NOT refused: `Intl` falls back for
|
|
124
|
+
* those, and a user carrying a locale the runtime has no data for must still get a rendered page.
|
|
125
|
+
*/
|
|
126
|
+
export function assertLocale(locale: string): string {
|
|
127
|
+
const tag = canonicalLocale(locale);
|
|
128
|
+
if (tag === undefined) throw localeInvalid(locale);
|
|
129
|
+
return tag;
|
|
130
|
+
}
|
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 {
|